diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:43:12 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:43:12 +0000 |
commit | cd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch) | |
tree | fbff2135e7013f196b891bbde54618eb050e4aaf /library | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.tar.xz icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.zip |
Adding upstream version 1.10.2.upstream/1.10.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library')
529 files changed, 68905 insertions, 0 deletions
diff --git a/library/Director/Acl.php b/library/Director/Acl.php new file mode 100644 index 0000000..4aa2bd2 --- /dev/null +++ b/library/Director/Acl.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director; + +use Icinga\Authentication\Auth; +use Icinga\Authentication\Role; +use Icinga\Exception\AuthenticationException; + +class Acl +{ + /** @var Auth */ + protected $auth; + + /** @var self */ + private static $instance; + + /** + * @return self + */ + public static function instance() + { + if (self::$instance === null) { + self::$instance = new static(Auth::getInstance()); + } + + return self::$instance; + } + + /** + * Acl constructor + * + * @param Auth $auth + */ + public function __construct(Auth $auth) + { + $this->auth = $auth; + } + + /** + * Whether the given permission is available + * + * @param $name + * + * @return bool + */ + public function hasPermission($name) + { + return $this->auth->hasPermission($name); + } + + /** + * List all given roles + * + * @return array + */ + public function listRoleNames() + { + return array_map( + array($this, 'getNameForRole'), + $this->getUser()->getRoles() + ); + } + + /** + * Get our user object, throws auth error if not available + * + * @return \Icinga\User + * @throws AuthenticationException + */ + protected function getUser() + { + if (null === ($user = $this->auth->getUser())) { + throw new AuthenticationException('Authenticated user required'); + } + + return $user; + } + + /** + * Get the name for a given role + * + * @param Role $role + * + * @return string + */ + protected function getNameForRole(Role $role) + { + return $role->getName(); + } +} diff --git a/library/Director/Application/Dependency.php b/library/Director/Application/Dependency.php new file mode 100644 index 0000000..0100e69 --- /dev/null +++ b/library/Director/Application/Dependency.php @@ -0,0 +1,113 @@ +<?php + +namespace Icinga\Module\Director\Application; + +class Dependency +{ + /** @var string */ + protected $name; + + /** @var string|null */ + protected $installedVersion; + + /** @var bool|null */ + protected $enabled; + + /** @var string */ + protected $operator; + + /** @var string */ + protected $requiredVersion; + + /** @var string */ + protected $requirement; + + /** + * Dependency constructor. + * @param string $name Usually a module name + * @param string $requirement e.g. >=1.7.0 + * @param string $installedVersion + * @param bool $enabled + */ + public function __construct($name, $requirement, $installedVersion = null, $enabled = null) + { + $this->name = $name; + $this->setRequirement($requirement); + if ($installedVersion !== null) { + $this->setInstalledVersion($installedVersion); + } + if ($enabled !== null) { + $this->setEnabled($enabled); + } + } + + public function setRequirement($requirement) + { + if (preg_match('/^([<>=]+)\s*v?(\d+\.\d+\.\d+)$/', $requirement, $match)) { + $this->operator = $match[1]; + $this->requiredVersion = $match[2]; + $this->requirement = $requirement; + } else { + throw new \InvalidArgumentException("'$requirement' is not a valid version constraint"); + } + } + + /** + * @return bool + */ + public function isInstalled() + { + return $this->installedVersion !== null; + } + + /** + * @return string|null + */ + public function getInstalledVersion() + { + return $this->installedVersion; + } + + /** + * @param string $version + */ + public function setInstalledVersion($version) + { + $this->installedVersion = ltrim($version, 'v'); // v0.6.0 VS 0.6.0 + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->enabled === true; + } + + /** + * @param bool $enabled + */ + public function setEnabled($enabled = true) + { + $this->enabled = $enabled; + } + + public function isSatisfied() + { + if (! $this->isInstalled() || ! $this->isEnabled()) { + return false; + } + + return version_compare($this->installedVersion, $this->requiredVersion, $this->operator); + } + + public function getName() + { + return $this->name; + } + + public function getRequirement() + { + return $this->requirement; + } +} diff --git a/library/Director/Application/DependencyChecker.php b/library/Director/Application/DependencyChecker.php new file mode 100644 index 0000000..d726b0b --- /dev/null +++ b/library/Director/Application/DependencyChecker.php @@ -0,0 +1,73 @@ +<?php + +namespace Icinga\Module\Director\Application; + +use Icinga\Application\ApplicationBootstrap; +use Icinga\Application\Modules\Module; +use Icinga\Application\Version; + +class DependencyChecker +{ + /** @var ApplicationBootstrap */ + protected $app; + + /** @var \Icinga\Application\Modules\Manager */ + protected $modules; + + public function __construct(ApplicationBootstrap $app) + { + $this->app = $app; + $this->modules = $app->getModuleManager(); + } + + /** + * @param Module $module + * @return Dependency[] + */ + public function getDependencies(Module $module) + { + $dependencies = []; + $isV290 = version_compare(Version::VERSION, '2.9.0', '>='); + foreach ($module->getDependencies() as $moduleName => $required) { + if ($isV290 && in_array($moduleName, ['ipl', 'reactbundle'], true)) { + continue; + } + $dependency = new Dependency($moduleName, $required); + $dependency->setEnabled($this->modules->hasEnabled($moduleName)); + if ($this->modules->hasInstalled($moduleName)) { + $dependency->setInstalledVersion($this->modules->getModule($moduleName, false)->getVersion()); + } + $dependencies[] = $dependency; + } + if ($isV290) { + $libs = $this->app->getLibraries(); + foreach ($module->getRequiredLibraries() as $libraryName => $required) { + $dependency = new Dependency($libraryName, $required); + if ($libs->has($libraryName)) { + $dependency->setInstalledVersion($libs->get($libraryName)->getVersion()); + $dependency->setEnabled(); + } + $dependencies[] = $dependency; + } + } + + return $dependencies; + } + + // if (version_compare(Version::VERSION, '2.9.0', 'ge')) { + // } + /** + * @param Module $module + * @return bool + */ + public function satisfiesDependencies(Module $module) + { + foreach ($this->getDependencies($module) as $dependency) { + if (! $dependency->isSatisfied()) { + return false; + } + } + + return true; + } +} diff --git a/library/Director/Application/MemoryLimit.php b/library/Director/Application/MemoryLimit.php new file mode 100644 index 0000000..beb0460 --- /dev/null +++ b/library/Director/Application/MemoryLimit.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Director\Application; + +class MemoryLimit +{ + public static function raiseTo($string) + { + $current = static::getBytes(); + $desired = static::parsePhpIniByteString($string); + if ($current !== -1 && $current < $desired) { + ini_set('memory_limit', $string); + } + } + + public static function getBytes() + { + return static::parsePhpIniByteString((string) ini_get('memory_limit')); + } + + /** + * Return Bytes from PHP shorthand bytes notation + * + * http://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes + * + * > The available options are K (for Kilobytes), M (for Megabytes) and G + * > (for Gigabytes), and are all case-insensitive. Anything else assumes + * > bytes. + * + * @param $string + * @return int + */ + public static function parsePhpIniByteString($string) + { + $val = trim($string); + + if (preg_match('/^(\d+)([KMG])$/', $val, $m)) { + $val = $m[1]; + switch ($m[2]) { + case 'G': + $val *= 1024; + // Intentional fall-through + case 'M': + $val *= 1024; + // Intentional fall-through + case 'K': + $val *= 1024; + } + } + + return intval($val); + } +} diff --git a/library/Director/CheckPlugin/Check.php b/library/Director/CheckPlugin/Check.php new file mode 100644 index 0000000..d05f5a7 --- /dev/null +++ b/library/Director/CheckPlugin/Check.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +use Exception; + +class Check extends CheckResults +{ + public function call(callable $check, $errorState = 'CRITICAL') + { + try { + $check(); + } catch (Exception $e) { + $this->fail($e, $errorState); + } + + return $this; + } + + public function assertTrue($check, $message, $errorState = 'CRITICAL') + { + if ($this->makeBool($check, $message) === true) { + $this->succeed($message); + } else { + $this->fail($message, $errorState); + } + + return $this; + } + + public function assertFalse($check, $message, $errorState = 'CRITICAL') + { + if ($this->makeBool($check, $message) === false) { + $this->succeed($message); + } else { + $this->fail($message, $errorState); + } + + return $this; + } + + protected function makeBool($check, &$message) + { + if (is_callable($check)) { + try { + $check = $check(); + } catch (Exception $e) { + $message .= ': ' . $e->getMessage(); + return false; + } + } + + if (! is_bool($check)) { + return null; + } + + return $check; + } +} diff --git a/library/Director/CheckPlugin/CheckResult.php b/library/Director/CheckPlugin/CheckResult.php new file mode 100644 index 0000000..cdf9b0d --- /dev/null +++ b/library/Director/CheckPlugin/CheckResult.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +class CheckResult +{ + protected $state; + + protected $output; + + public function __construct($output, $state = 0) + { + if ($state instanceof PluginState) { + $this->state = $state; + } else { + $this->state = new PluginState($state); + } + + $this->output = $output; + } + + public function getState() + { + return $this->state; + } + + public function getOutput() + { + return $this->output; + } +} diff --git a/library/Director/CheckPlugin/CheckResults.php b/library/Director/CheckPlugin/CheckResults.php new file mode 100644 index 0000000..7e20225 --- /dev/null +++ b/library/Director/CheckPlugin/CheckResults.php @@ -0,0 +1,150 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +use Exception; + +class CheckResults +{ + /** @var string */ + protected $name; + + /** @var PluginState */ + protected $state; + + /** @var CheckResult[] */ + protected $results = []; + + protected $stateCounters = [ + 0 => 0, + 1 => 0, + 2 => 0, + 3 => 0, + ]; + + public function __construct($name) + { + $this->name = $name; + $this->state = new PluginState(0); + } + + public function getName() + { + return $this->name; + } + + public function add(CheckResult $result) + { + $this->results[] = $result; + $this->state->raise($result->getState()); + $this->stateCounters[$result->getState()->getNumeric()]++; + + return $this; + } + + public function getStateCounters() + { + return $this->stateCounters; + } + + public function getProblemSummary() + { + $summary = []; + for ($i = 1; $i <= 3; $i++) { + $count = $this->stateCounters[$i]; + if ($count === 0) { + continue; + } + $summary[PluginState::create($i)->getName()] = $count; + } + + return $summary; + } + + public function getStateSummaryString() + { + $summary = [sprintf( + '%d tests OK', + $this->stateCounters[0] + )]; + + for ($i = 1; $i <= 3; $i++) { + $count = $this->stateCounters[$i]; + if ($count === 0) { + continue; + } + $summary[] = sprintf( + '%dx %s', + $count, + PluginState::create($i)->getName() + ); + } + + return implode(', ', $summary); + } + + public function getOutput() + { + $output = sprintf( + "%s: %s\n", + $this->name, + $this->getStateSummaryString() + ); + + foreach ($this->results as $result) { + $output .= sprintf( + "[%s] %s\n", + $result->getState()->getName(), + $result->getOutput() + ); + } + + return $output; + } + + public function getResults() + { + return $this->results; + } + + public function getState() + { + return $this->state; + } + + public function hasProblems() + { + return $this->getState()->getNumeric() !== 0; + } + + public function hasErrors() + { + $state = $this->getState()->getNumeric(); + return $state !== 0 && $state !== 1; + } + + public function succeed($message) + { + $this->add(new CheckResult($message)); + + return $this; + } + + public function warn($message) + { + $this->add(new CheckResult($message, 1)); + + return $this; + } + + public function fail($message, $errorState = 'CRITICAL') + { + if ($message instanceof Exception) { + $message = $message->getMessage(); + } + + $this->add(new CheckResult($message, $errorState)); + + return $this; + } +} diff --git a/library/Director/CheckPlugin/PluginState.php b/library/Director/CheckPlugin/PluginState.php new file mode 100644 index 0000000..d68ec70 --- /dev/null +++ b/library/Director/CheckPlugin/PluginState.php @@ -0,0 +1,114 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +use Icinga\Exception\ProgrammingError; + +class PluginState +{ + protected static $stateCodes = [ + 'UNKNOWN' => 3, + 'CRITICAL' => 2, + 'WARNING' => 1, + 'OK' => 0, + ]; + + protected static $stateNames = [ + 'OK', + 'WARNING', + 'CRITICAL', + 'UNKNOWN', + ]; + + protected static $sortSeverity = [0, 1, 3, 2]; + + /** @var int */ + protected $state; + + public function __construct($state) + { + $this->set($state); + } + + public function isProblem() + { + return $this->state > 0; + } + + public function set($state) + { + $this->state = $this->getNumericStateFor($state); + } + + public function getNumeric() + { + return $this->state; + } + + public function getSortSeverity() + { + return static::getSortSeverityFor($this->getNumeric()); + } + + public function getName() + { + return self::$stateNames[$this->getNumeric()]; + } + + public function raise(PluginState $state) + { + if ($this->getSortSeverity() < $state->getSortSeverity()) { + $this->state = $state->getNumeric(); + } + + return $this; + } + + public static function create($state) + { + return new static($state); + } + + public static function ok() + { + return new static(0); + } + + public static function warning() + { + return new static(1); + } + + public static function critical() + { + return new static(2); + } + + public static function unknown() + { + return new static(3); + } + + protected static function getNumericStateFor($state) + { + if ((is_int($state) || ctype_digit($state)) && $state >= 0 && $state <= 3) { + return (int) $state; + } elseif (is_string($state) && array_key_exists($state, self::$stateCodes)) { + return self::$stateCodes[$state]; + } else { + throw new ProgrammingError('Expected valid state, got: %s', $state); + } + } + + protected static function getSortSeverityFor($state) + { + if (array_key_exists($state, self::$sortSeverity)) { + return self::$sortSeverity[$state]; + } else { + throw new ProgrammingError( + 'Unable to retrieve sort severity for invalid state: %s', + $state + ); + } + } +} diff --git a/library/Director/CheckPlugin/Range.php b/library/Director/CheckPlugin/Range.php new file mode 100644 index 0000000..d7b582e --- /dev/null +++ b/library/Director/CheckPlugin/Range.php @@ -0,0 +1,101 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +use Icinga\Exception\ConfigurationError; + +class Range +{ + /** @var float|null */ + protected $start = 0; + + /** @var float|null */ + protected $end = null; + + /** @var bool */ + protected $mustBeWithinRange = true; + + public function __construct($start = 0, $end = null, $mustBeWithinRange = true) + { + $this->start = $start; + $this->end = $end; + $this->mustBeWithinRange = $mustBeWithinRange; + } + + public function valueIsValid($value) + { + if ($this->valueIsWithinRange($value)) { + return $this->valueMustBeWithinRange(); + } else { + return ! $this->valueMustBeWithinRange(); + } + } + + public function valueIsWithinRange($value) + { + if ($this->start !== null && $value < $this->start) { + return false; + } + if ($this->end !== null && $value > $this->end) { + return false; + } + + return true; + } + + public function valueMustBeWithinRange() + { + return $this->mustBeWithinRange; + } + + /** + * @param $any + * @return static + */ + public static function wantRange($any) + { + if ($any instanceof static) { + return $any; + } else { + return static::parse($any); + } + } + + /** + * @param $string + * @return static + * @throws ConfigurationError + */ + public static function parse($string) + { + $string = str_replace(' ', '', $string); + $value = '[-+]?[\d\.]+'; + $valueRe = "$value(?:e$value)?"; + $regex = "/^(@)?($valueRe|~)(:$valueRe|~)?/"; + if (! preg_match($regex, $string, $match)) { + throw new ConfigurationError('Invalid range definition: %s', $string); + } + + $inside = $match[1] === '@'; + + if (strlen($match[3]) === 0) { + $start = 0; + $end = static::parseValue($match[2]); + } else { + $start = static::parseValue($match[2]); + $end = static::parseValue($match[3]); + } + $range = new static($start, $end, $inside); + + return $range; + } + + protected static function parseValue($value) + { + if ($value === '~') { + return null; + } else { + return $value; + } + } +} diff --git a/library/Director/CheckPlugin/Threshold.php b/library/Director/CheckPlugin/Threshold.php new file mode 100644 index 0000000..76aac4e --- /dev/null +++ b/library/Director/CheckPlugin/Threshold.php @@ -0,0 +1,47 @@ +<?php + +namespace Icinga\Module\Director\CheckPlugin; + +class Threshold +{ + /** @var Range */ + protected $warning; + + /** @var Range */ + protected $critical; + + public function __construct($warning = null, $critical = null) + { + if ($warning !== null) { + $this->warning = Range::wantRange($warning); + } + + if ($critical !== null) { + $this->critical = Range::wantRange($critical); + } + } + + public static function check($value, $message, $warning = null, $critical = null) + { + $threshold = new static($warning, $critical); + $state = $threshold->checkValue($value); + return new CheckResult($message, $state); + } + + public function checkValue($value) + { + if ($this->critical !== null) { + if (! $this->critical->valueIsValid($value)) { + return PluginState::critical(); + } + } + + if ($this->warning !== null) { + if (! $this->warning->valueIsValid($value)) { + return PluginState::warning(); + } + } + + return PluginState::ok(); + } +} diff --git a/library/Director/Cli/Command.php b/library/Director/Cli/Command.php new file mode 100644 index 0000000..69d61b1 --- /dev/null +++ b/library/Director/Cli/Command.php @@ -0,0 +1,115 @@ +<?php + +namespace Icinga\Module\Director\Cli; + +use gipfl\Json\JsonDecodeException; +use gipfl\Json\JsonString; +use Icinga\Cli\Command as CliCommand; +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Application\Config; +use RuntimeException; + +class Command extends CliCommand +{ + /** @var Db */ + protected $db; + + /** @var CoreApi */ + private $api; + + protected function renderJson($object, $pretty = true) + { + return JsonString::encode($object, $pretty ? JSON_PRETTY_PRINT : null) . "\n"; + } + + /** + * @param $json + * @return mixed + */ + protected function parseJson($json) + { + try { + return JsonString::decode($json); + } catch (JsonDecodeException $e) { + $this->fail('Invalid JSON: %s', $e->getMessage()); + } + } + + /** + * @param string $msg + * @return never-return + */ + public function fail($msg) + { + $args = func_get_args(); + array_shift($args); + if (count($args)) { + $msg = vsprintf($msg, $args); + } + echo $this->screen->colorize("ERROR", 'red') . ": $msg\n"; + exit(1); + } + + /** + * @param null $endpointName + * @return CoreApi|\Icinga\Module\Director\Core\LegacyDeploymentApi + * @throws \Icinga\Exception\NotFoundError + */ + protected function api($endpointName = null) + { + if ($this->api === null) { + if ($endpointName === null) { + $endpoint = $this->db()->getDeploymentEndpoint(); + } else { + $endpoint = IcingaEndpoint::load($endpointName, $this->db()); + } + + $this->api = $endpoint->api(); + } + + return $this->api; + } + + /** + * Raise PHP resource limits + * + * @return self; + */ + protected function raiseLimits() + { + MemoryLimit::raiseTo('1024M'); + + ini_set('max_execution_time', 0); + if (version_compare(PHP_VERSION, '7.0.0') < 0) { + ini_set('zend.enable_gc', 0); + } + + return $this; + } + + /** + * @return Db + */ + protected function db() + { + if ($this->db === null) { + $resourceName = $this->params->get('dbResourceName'); + + if ($resourceName === null) { + // Hint: not using $this->Config() intentionally. This allows + // CLI commands in other modules to use this as a base class. + $resourceName = Config::module('director')->get('db', 'resource'); + } + if ($resourceName) { + $this->db = Db::fromResourceName($resourceName); + } else { + throw new RuntimeException('Director is not configured correctly'); + } + } + + return $this->db; + } +} diff --git a/library/Director/Cli/ObjectCommand.php b/library/Director/Cli/ObjectCommand.php new file mode 100644 index 0000000..ca68213 --- /dev/null +++ b/library/Director/Cli/ObjectCommand.php @@ -0,0 +1,517 @@ +<?php + +namespace Icinga\Module\Director\Cli; + +use Icinga\Cli\Params; +use Icinga\Exception\MissingParameterException; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Data\PropertyMangler; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use InvalidArgumentException; + +class ObjectCommand extends Command +{ + protected $type; + + private $name; + + private $object; + + private $experimentalFlags = array(); + + public function init() + { + $this->shiftExperimentalFlags(); + } + + /** + * Show a specific object + * + * Use this command to show single objects rendered as Icinga 2 + * config or in JSON format. + * + * USAGE + * + * icingacli director <type> show <name> [options] + * + * OPTIONS + * + * --resolved Resolve all inherited properties and show a flat + * object + * --json Use JSON format + * --no-pretty JSON is pretty-printed per default. Use this flag + * to enforce un-formatted JSON + * --no-defaults Per default JSON output ships null or default + * values. This flag skips those properties + * --with-services For hosts only, also shows attached services + * --all-services For hosts only, show applied and inherited services + * too + */ + public function showAction() + { + $db = $this->db(); + $object = $this->getObject(); + $exporter = new Exporter($db); + $resolve = (bool) $this->params->shift('resolved'); + $withServices = (bool) $this->params->get('with-services'); + $allServices = (bool) $this->params->get('all-services'); + if ($withServices) { + if (!$object instanceof IcingaHost) { + $this->fail('--with-services is available for Hosts only'); + } + $exporter->enableHostServices(); + } + if ($allServices) { + if (!$object instanceof IcingaHost) { + $this->fail('--all-services is available for Hosts only'); + } + $exporter->serviceLoader()->resolveHostServices(); + } + + $exporter->resolveObjects($resolve); + $exporter->showDefaults($this->params->shift('no-defaults', false)); + + if ($this->params->shift('json')) { + echo $this->renderJson($exporter->export($object), !$this->params->shift('no-pretty')); + } else { + $config = new IcingaConfig($db); + if ($resolve) { + $object = $object::fromPlainObject($object->toPlainObject(true, false, null, false), $db); + } + $object->renderToConfig($config); + if ($withServices) { + foreach ($exporter->serviceLoader()->fetchServicesForHost($object) as $service) { + $service->renderToConfig($config); + } + } + foreach ($config->getFiles() as $filename => $content) { + printf("/** %s **/\n\n", $filename); + echo $content; + } + } + } + + /** + * Create a new object + * + * Use this command to create a new Icinga object + * + * USAGE + * + * icingacli director <type> create [<name>] [options] + * + * OPTIONS + * + * --<key> <value> Provide all properties as single command line + * options + * --json Otherwise provide all options as a JSON string + * + * EXAMPLES + * + * icingacli director host create localhost \ + * --imports generic-host \ + * --address 127.0.0.1 \ + * --vars.location 'My datacenter' + * + * icingacli director host create localhost \ + * --json '{ "address": "127.0.0.1" }' + */ + public function createAction() + { + $type = $this->getType(); + $props = $this->getObjectProperties(); + $name = $props['object_name']; + $object = IcingaObject::createByType($type, $props, $this->db()); + + if ($object->store()) { + printf("%s '%s' has been created\n", $type, $name); + if ($this->hasExperimental('live-creation')) { + if ($this->api()->createObjectAtRuntime($object)) { + echo "Live creation for '$name' succeeded\n"; + } else { + echo "Live creation for '$name' succeeded\n"; + exit(1); + } + + if ($type === 'Host' && $this->hasExperimental('immediate-check')) { + echo "Waiting for check result..."; + flush(); + if ($res = $this->api()->checkHostAndWaitForResult($name)) { + echo " done\n" . $res->output . "\n"; + } else { + echo "TIMEOUT\n"; + } + } + } + + exit(0); + } else { + printf("%s '%s' has not been created\n", $type, $name); + exit(1); + } + } + + /** + * Modify an existing objects properties + * + * Use this command to modify specific properties of an existing + * Icinga object + * + * USAGE + * + * icingacli director <type> set <name> [options] + * + * OPTIONS + * + * --<key> <value> Provide all properties as single command line + * options + * --append-<key> <value> Appends to array values, like `imports`, + * `groups` or `vars.system_owners` + * --remove-<key> [<value>] Remove a specific property, eventually only + * when matching `value`. In case the property is an + * array it will remove just `value` when given + * --json Otherwise provide all options as a JSON string + * --replace Replace all object properties with the given ones + * --auto-create Create the object in case it does not exist + * + * EXAMPLES + * + * icingacli director host set localhost \ + * --address 127.0.0.2 \ + * --vars.location 'Somewhere else' + * + * icingacli director host set localhost \ + * --json '{ "address": "127.0.0.2" }' + */ + public function setAction() + { + $name = $this->getName(); + $type = $this->getType(); + + if ($this->params->shift('auto-create') && ! $this->exists($name)) { + $action = 'created'; + $object = $this->create($type, $name); + } else { + $action = 'modified'; + $object = $this->getObject(); + } + + $appends = self::stripPrefixedProperties($this->params, 'append-'); + $remove = self::stripPrefixedProperties($this->params, 'remove-'); + + if ($this->params->shift('replace')) { + $object->replaceWith($this->create($type, $name, $this->remainingParams())); + } else { + $object->setProperties($this->remainingParams()); + } + + PropertyMangler::appendToArrayProperties($object, $appends); + PropertyMangler::removeProperties($object, $remove); + $this->persistChanges($object, $type, $name, $action); + } + + protected function persistChanges(DbObject $object, $type, $name, $action) + { + if ($object->hasBeenModified() && $object->store()) { + printf("%s '%s' has been %s\n", $type, $name, $action); + exit(0); + } + + printf("%s '%s' has not been modified\n", $type, $name); + exit(0); + } + + /** + * Delete a specific object + * + * Use this command to delete a single Icinga object + * + * USAGE + * + * icingacli director <type> delete <name> + * + * EXAMPLES + * + * icingacli director host delete localhost2 + * + * icingacli director host delete localhost{3..8} + */ + public function deleteAction() + { + $type = $this->getType(); + + foreach ($this->shiftOneOrMoreNames() as $name) { + if ($this->load($name)->delete()) { + printf("%s '%s' has been deleted\n", $type, $name); + } else { + printf("Something went wrong while deleting %s '%s'\n", $type, $name); + exit(1); + } + + $this->object = null; + } + exit(0); + } + + /** + * Whether a specific object exists + * + * Use this command to find out whether a single Icinga object exists + * + * USAGE + * + * icingacli director <type> exists <name> + */ + public function existsAction() + { + $name = $this->getName(); + $type = $this->getType(); + if ($this->exists($name)) { + printf("%s '%s' exists\n", $type, $name); + exit(0); + } else { + printf("%s '%s' does not exist\n", $type, $name); + exit(1); + } + } + + /** + * Clone an existing object + * + * Use this command to clone a specific object + * + * USAGE + * + * icingacli director <type> clone <name> --from <original> [options] + * + * OPTIONS + * --from <original> The name of the object you want to clone + * --<key> <value> Override specific properties while cloning + * --replace In case an object <name> already exists replace + * it with the clone + * --flat Do no keep inherited properties but create a flat + * object with all resolved/inherited properties + * + * EXAMPLES + * + * icingacli director host clone localhost2 --from localhost + * + * icingacli director host clone localhost{3..8} --from localhost2 + * + * icingacli director host clone localhost3 --from localhost \ + * --address 127.0.0.3 + */ + public function cloneAction() + { + $fromName = $this->params->shiftRequired('from'); + $from = $this->load($fromName); + + // $name = $this->getName(); + $type = $this->getType(); + + $resolve = $this->params->shift('flat'); + $replace = $this->params->shift('replace'); + + $from->setProperties($this->remainingParams()); + + foreach ($this->shiftOneOrMoreNames() as $name) { + $object = $from::fromPlainObject( + $from->toPlainObject($resolve), + $from->getConnection() + ); + + $object->set('object_name', $name); + + if ($replace && $this->exists($name)) { + $object = $this->load($name)->replaceWith($object); + } + + if ($object->hasBeenModified() && $object->store()) { + printf("%s '%s' has been cloned from %s\n", $type, $name, $fromName); + } else { + printf("%s '%s' has not been modified\n", $this->getType(), $name); + } + } + + exit(0); + } + + protected static function stripPrefixedProperties(Params $params, $prefix = 'append-') + { + $appends = []; + $len = strlen($prefix); + + foreach ($params->getParams() as $key => $value) { + if (substr($key, 0, $len) === $prefix) { + $appends[substr($key, $len)] = $value; + } + } + + foreach ($appends as $key => $value) { + $params->shift("$prefix$key"); + } + + return $appends; + } + + protected function getObjectProperties() + { + $name = $this->params->shift(); + + $props = $this->remainingParams(); + if (! array_key_exists('object_type', $props)) { + $props['object_type'] = 'object'; + } + + // Normalize object_name, compare to given name + if ($name) { + if (array_key_exists('object_name', $props)) { + if ($name !== $props['object_name']) { + $this->fail(sprintf( + "Name '%s' conflicts with object_name '%s'\n", + $name, + $props['object_name'] + )); + } + } else { + $props['object_name'] = $name; + } + } else { + if (! array_key_exists('object_name', $props)) { + $this->fail('Cannot create an object with at least an object name'); + } + } + + return $props; + } + + protected function shiftOneOrMoreNames() + { + $names = array(); + while ($name = $this->params->shift()) { + $names[] = $name; + } + + if (empty($names)) { + throw new MissingParameterException('Required object name is missing'); + } + + return $names; + } + + protected function remainingParams() + { + if ($json = $this->params->shift('json')) { + if ($json === true) { + $json = $this->readFromStdin(); + if ($json === null) { + $this->fail('Please pass JSON either via STDIN or via --json'); + } + } + return (array) $this->parseJson($json); + } else { + return $this->params->getParams(); + } + } + + protected function readFromStdin() + { + if (!defined('STDIN')) { + define('STDIN', fopen('php://stdin', 'r')); + } + $inputIsTty = function_exists('posix_isatty') && posix_isatty(STDIN); + if ($inputIsTty) { + return null; + } + + $stdin = file_get_contents('php://stdin'); + if (strlen($stdin) === 0) { + return null; + } + + return $stdin; + } + + protected function exists($name) + { + return IcingaObject::existsByType( + $this->getType(), + $name, + $this->db() + ); + } + + protected function load($name) + { + return IcingaObject::loadByType( + $this->getType(), + $name, + $this->db() + ); + } + + protected function create($type, $name, $properties = []) + { + return IcingaObject::createByType($type, $properties + [ + 'object_type' => 'object', + 'object_name' => $name + ], $this->db()); + } + + /** + * @return IcingaObject + */ + protected function getObject() + { + if ($this->object === null) { + $this->object = $this->load($this->getName()); + } + + return $this->object; + } + + protected function getType() + { + if ($this->type === null) { + // Extract the command class name... + $className = substr(strrchr(get_class($this), '\\'), 1); + // ...and strip the Command extension + $this->type = substr($className, 0, -7); + } + + return $this->type; + } + + protected function getName() + { + if ($this->name === null) { + $name = $this->params->shift(); + if (! $name) { + throw new InvalidArgumentException('Object name parameter is required'); + } + + $this->name = $name; + } + + return $this->name; + } + + protected function hasExperimental($flag) + { + return array_key_exists($flag, $this->experimentalFlags); + } + + protected function shiftExperimentalFlags() + { + if ($flags = $this->params->shift('experimental')) { + foreach (preg_split('/,/', $flags) as $flag) { + $this->experimentalFlags[$flag] = true; + } + } + + return $this; + } +} diff --git a/library/Director/Cli/ObjectsCommand.php b/library/Director/Cli/ObjectsCommand.php new file mode 100644 index 0000000..3e0844a --- /dev/null +++ b/library/Director/Cli/ObjectsCommand.php @@ -0,0 +1,115 @@ +<?php + +namespace Icinga\Module\Director\Cli; + +use Icinga\Module\Director\Objects\IcingaObject; + +class ObjectsCommand extends Command +{ + protected $type; + + private $objects; + + /** + * List all objects of this type + * + * Use this command to get a list of all matching objects + * + * USAGE + * + * icingacli director <types> list [options] + * + * OPTIONS + * + * --json Use JSON format + * --no-pretty JSON is pretty-printed per default (for PHP >= 5.4) + * Use this flag to enforce unformatted JSON + */ + public function listAction() + { + $db = $this->db(); + $result = array(); + foreach ($this->getObjects() as $o) { + $result[] = $o->getObjectName(); + } + + sort($result); + + if ($this->params->shift('json')) { + echo $this->renderJson($result, !$this->params->shift('no-pretty')); + } else { + foreach ($result as $name) { + echo $name . "\n"; + } + } + } + + /** + * Fetch all objects of this type + * + * Use this command to fetch all matching objects + * + * USAGE + * + * icingacli director <types> fetch [options] + * + * OPTIONS + * + * --resolved Resolve all inherited properties and show a flat + * object + * --json Use JSON format + * --no-pretty JSON is pretty-printed per default (for PHP >= 5.4) + * Use this flag to enforce unformatted JSON + * --no-defaults Per default JSON output ships null or default values + * With this flag you will skip those properties + */ + public function fetchAction() + { + $resolved = $this->params->shift('resolved'); + + if ($this->params->shift('json')) { + $noDefaults = $this->params->shift('no-defaults', false); + } else { + $this->fail('Currently only json is supported when fetching objects'); + } + + $db = $this->db(); + $res = array(); + foreach ($this->getObjects() as $object) { + if ($resolved) { + $object = $object::fromPlainObject($object->toPlainObject(true), $db); + } + + $res[$object->getObjectName()] = $object->toPlainObject(false, $noDefaults); + } + + echo $this->renderJson($res, !$this->params->shift('no-pretty')); + } + + /** + * @return IcingaObject[] + */ + protected function getObjects() + { + if ($this->objects === null) { + $this->objects = IcingaObject::loadAllByType( + $this->getType(), + $this->db() + ); + } + + return $this->objects; + } + + protected function getType() + { + if ($this->type === null) { + // Extract the command class name... + $className = substr(strrchr(get_class($this), '\\'), 1); + // ...and strip the Command extension + $this->type = rtrim(substr($className, 0, -7), 's'); + } + + return $this->type; + } +} diff --git a/library/Director/Cli/PluginOutputBeautifier.php b/library/Director/Cli/PluginOutputBeautifier.php new file mode 100644 index 0000000..18a18f2 --- /dev/null +++ b/library/Director/Cli/PluginOutputBeautifier.php @@ -0,0 +1,75 @@ +<?php + +namespace Icinga\Module\Director\Cli; + +use Icinga\Cli\Screen; + +class PluginOutputBeautifier +{ + /** @var Screen */ + protected $screen; + + protected $isTty; + + protected $colorized; + + public function __construct(Screen $screen) + { + $this->screen = $screen; + } + + public static function beautify($string, Screen $screen) + { + $self = new static($screen); + if ($self->isTty()) { + return $self->colorizeStates($string); + } else { + return $string; + } + } + + protected function colorizeStates($string) + { + $string = preg_replace_callback( + "/'([^']+)'/", + [$this, 'highlightNames'], + $string + ); + + $string = preg_replace_callback( + '/(OK|WARNING|CRITICAL|UNKNOWN)/', + [$this, 'getColorized'], + $string + ); + + return $string; + } + + protected function isTty() + { + if ($this->isTty === null) { + $this->isTty = function_exists('posix_isatty') && posix_isatty(STDOUT); + } + + return $this->isTty; + } + + protected function highlightNames($match) + { + return "'" . $this->screen->colorize($match[1], 'darkgray') . "'"; + } + + protected function getColorized($match) + { + if ($this->colorized === null) { + $this->colorized = [ + 'OK' => $this->screen->colorize('OK', 'lightgreen'), + 'WARNING' => $this->screen->colorize('WARNING', 'yellow'), + 'CRITICAL' => $this->screen->colorize('CRITICAL', 'lightred'), + 'UNKNOWN' => $this->screen->colorize('UNKNOWN', 'lightpurple'), + ]; + } + + return $this->colorized[$match[1]]; + } +} diff --git a/library/Director/ConfigDiff.php b/library/Director/ConfigDiff.php new file mode 100644 index 0000000..acf5f7b --- /dev/null +++ b/library/Director/ConfigDiff.php @@ -0,0 +1,47 @@ +<?php + +namespace Icinga\Module\Director; + +use gipfl\Diff\HtmlRenderer\InlineDiff; +use gipfl\Diff\HtmlRenderer\SideBySideDiff; +use gipfl\Diff\PhpDiff; +use ipl\Html\ValidHtml; +use InvalidArgumentException; + +/** + * @deprecated will be removed with v1.9 - please use gipfl\Diff + */ +class ConfigDiff implements ValidHtml +{ + protected $renderClass; + + /** @var PhpDiff */ + protected $phpDiff; + + public function __construct($a, $b) + { + $this->phpDiff = new PhpDiff($a, $b); + } + + public function render() + { + $class = $this->renderClass; + return (new $class($this->phpDiff))->render(); + } + + public function setHtmlRenderer($name) + { + switch ($name) { + case 'SideBySide': + $this->renderClass = SideBySideDiff::class; + break; + case 'Inline': + $this->renderClass = InlineDiff::class; + break; + default: + throw new InvalidArgumentException("There is no known '$name' renderer"); + } + + return $this; + } +} diff --git a/library/Director/Core/CoreApi.php b/library/Director/Core/CoreApi.php new file mode 100644 index 0000000..ea10916 --- /dev/null +++ b/library/Director/Core/CoreApi.php @@ -0,0 +1,940 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Exception; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\DeploymentHook; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use Icinga\Module\Director\Objects\IcingaZone; +use Icinga\Web\Hook; +use RuntimeException; + +class CoreApi implements DeploymentApiInterface +{ + protected $client; + + protected $initialized = false; + + /** @var Db */ + protected $db; + + public function __construct(RestApiClient $client) + { + $this->client = $client; + } + + // Todo: type + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + /** + * @return string|null + */ + public function getVersion() + { + return $this->parseVersion($this->getRawVersion()); + } + + public function enableWorkaroundForConnectionIssues() + { + $version = $this->getVersion(); + + if ($version === null || + ((version_compare($version, '2.8.2', '>=') && version_compare($version, '2.10.2', '<'))) + ) { + $this->client->disconnect(); + $this->client->setKeepAlive(false); + } + } + + /** + * @return string|null + */ + public function getRawVersion() + { + try { + return $this->client()->get('')->getRaw('version'); + } catch (Exception $exception) { + return null; + } + } + + /** + * @param $version + * @return string|null + */ + protected function parseVersion($version) + { + if ($version === null) { + return null; + } + + if (preg_match('/^[rv]?(\d\.\d+\.\d+)/', $version, $match)) { + return $match[1]; + } else { + return null; + } + } + + public function getObjects($pluralType, $attrs = array(), $ignorePackage = null) + { + $params = (object) []; + if ($ignorePackage) { + $params->filter = 'obj.package!="' . $ignorePackage . '"'; + } + + if (! empty($attrs)) { + $params->attrs = $attrs; + } + + return $this->client()->get( + 'objects/' . urlencode(strtolower($pluralType)), + $params + )->getResult('name'); + } + + public function onEvent($callback, $raw = false) + { + $this->client->onEvent($callback, $raw); + + return $this; + } + + public function getObject($name, $pluraltype, $attrs = array()) + { + $params = (object) array( + ); + + if (! empty($attrs)) { + $params->attrs = $attrs; + } + $url = 'objects/' . urlencode(strtolower($pluraltype)) . '/' . rawurlencode($name) . '?all_joins=1'; + $res = $this->client()->get($url, $params)->getResult('name'); + + // TODO: check key, throw + return $res[$name]; + } + + /** + * Get a PKI ticket for CSR auto-signing + * + * @param string $cn The host’s common name for which the ticket should be generated + * + * @return string|null + */ + public function getTicket($cn) + { + $r = $this->client()->post( + 'actions/generate-ticket', + ['cn' => $cn] + ); + if (! $r->succeeded()) { + throw new RuntimeException($r->getErrorMessage()); + } + + $ticket = $r->getRaw('ticket'); + if ($ticket === null) { + // RestApiResponse::succeeded() returns true if Icinga 2 reports an error in the results key, e.g. + // { + // "results": [ + // { + // "code": 500.0, + // "status": "Ticket salt is not configured in ApiListener object" + // } + // ] + // } + throw new RuntimeException($r->getRaw('status', 'Ticket is empty')); + } + + return $ticket; + } + + public function checkHostNow($host) + { + $filter = 'host.name == "' . $host . '"'; + + return $this->client()->post( + 'actions/reschedule-check?filter=' . rawurlencode($filter), + (object) array( + 'type' => 'Host' + ) + ); + } + + public function checkServiceNow($host, $service) + { + $filter = 'host.name == "' . $host . '" && service.name == "' . $service . '"'; + $this->client()->post( + 'actions/reschedule-check?filter=' . rawurlencode($filter), + (object) array( + 'type' => 'Service' + ) + ); + } + + public function acknowledgeHostProblem($host, $author, $comment) + { + $filter = 'host.name == "' . $host . '"'; + return $this->client()->post( + 'actions/acknowledge-problem?type=Host&filter=' . rawurlencode($filter), + (object) array( + 'author' => $author, + 'comment' => $comment + ) + ); + } + + public function removeHostAcknowledgement($host) + { + $filter = 'host.name == "' . $host . '"'; + return $this->client()->post( + 'actions/remove-acknowledgement?type=Host&filter=' . rawurlencode($filter) + ); + } + + public function reloadNow() + { + try { + $this->client()->post('actions/restart-process'); + + return true; + } catch (Exception $e) { + return $e->getMessage(); + } + } + + public function getHostOutput($host) + { + try { + $object = $this->getObject($host, 'hosts'); + } catch (Exception $e) { + return 'Unable to fetch the requested object'; + } + if (isset($object->attrs->last_check_result)) { + return $object->attrs->last_check_result->output; + } else { + return '(no check result available)'; + } + } + + public function checkHostAndWaitForResult($host, $timeout = 10) + { + $object = $this->getObject($host, 'hosts'); + if (isset($object->attrs->last_check_result)) { + $oldOutput = $object->attrs->last_check_result->output; + } else { + $oldOutput = ''; + } + + $now = microtime(true); + $this->checkHostNow($host); + + while (true) { + try { + $object = $this->getObject($host, 'hosts'); + if (isset($object->attrs->last_check_result)) { + $res = $object->attrs->last_check_result; + if ($res->execution_start > $now || $res->output !== $oldOutput) { + return $res; + } + } else { + // no check result available + } + } catch (Exception $e) { + // Unable to fetch the requested object + throw new RuntimeException(sprintf( + 'Unable to fetch the requested host "%s"', + $host + )); + } + if (microtime(true) > ($now + $timeout)) { + break; + } + + usleep(50000); + } + + return false; + } + + public function checkServiceAndWaitForResult($host, $service, $timeout = 10) + { + $now = microtime(true); + $this->checkServiceNow($host, $service); + + while (true) { + try { + $object = $this->getObject("$host!$service", 'services'); + if (isset($object->attrs->last_check_result)) { + $res = $object->attrs->last_check_result; + if ($res->execution_start > $now) { + return $res; + } + } else { + // no check result available + } + } catch (Exception $e) { + // Unable to fetch the requested object + throw new RuntimeException(sprintf( + 'Unable to fetch the requested service "%s" on "%s"', + $service, + $host + )); + } + if (microtime(true) > ($now + $timeout)) { + break; + } + + usleep(150000); + } + + return false; + } + + public function getServiceOutput($host, $service) + { + try { + $object = $this->getObject($host . '!' . $service, 'services'); + } catch (\Exception $e) { + return 'Unable to fetch the requested object'; + } + if (isset($object->attrs->last_check_result)) { + return $object->attrs->last_check_result->output; + } else { + return '(no check result available)'; + } + } + + public function supportsRuntimeCreationFor(IcingaObject $object) + { + $valid = array('host'); + return in_array($object->getShortTableName(), $valid); + } + + protected function assertRuntimeCreationSupportFor(IcingaObject $object) + { + if (!$this->supportsRuntimeCreationFor($object)) { + throw new RuntimeException(sprintf( + 'Object creation at runtime is not supported for "%s"', + $object->getShortTableName() + )); + } + } + + // Note: this is for testing purposes only, NOT production-ready + public function createObjectAtRuntime(IcingaObject $object) + { + $this->assertRuntimeCreationSupportFor($object); + + $key = $object->getShortTableName(); + + $command = sprintf( + "f = function() {\n" + . ' existing = get_%s("%s")' + . "\n if (existing) { return false }" + . "\n%s\n}\nInternal.run_with_activation_context(f)\n", + $key, + $object->get('object_name'), + (string) $object + ); + + return $this->runConsoleCommand($command)->getSingleResult(); + } + + public function getConstants() + { + $constants = array(); + $command = 'var constants = []; +for (k => v in globals) { + if (typeof(v) in [String, Number, Boolean]) { + res = { name = k, value = v } + constants.add({name = k, value = v}) + } +}; +constants +'; + + foreach ($this->runConsoleCommand($command)->getSingleResult() as $row) { + $constants[$row->name] = $row->value; + } + + return $constants; + } + + public function runConsoleCommand($command) + { + return $this->client()->post( + 'console/execute-script', + array('command' => $command) + ); + } + + public function getConstant($name) + { + $constants = $this->getConstants(); + if (array_key_exists($name, $constants)) { + return $constants[$name]; + } + + return null; + } + + public function getTypes() + { + return $this->client()->get('types')->getResult('name'); + } + + public function getType($type) + { + $res = $this->client()->get('types', array('name' => $type))->getResult('name'); + return $res[$type]; // TODO: error checking + } + + public function getStatus() + { + return $this->client()->get('status')->getResult('name'); + } + + public function listObjects($type, $pluralType) + { + // TODO: more abstraction needed + // TODO: autofetch and cache pluraltypes + try { + $result = $this->client()->get( + 'objects/' . $pluralType, + array( + 'attrs' => array('__name') + ) + )->getResult('name'); + } catch (NotFoundError $e) { + $result = []; + } + + return array_keys($result); + } + + public function getPackages() + { + return $this->client()->get('config/packages')->getResult('name'); + } + + public function getActiveStageName() + { + return current($this->listPackageStages($this->getPackageName(), true)); + } + + protected function getPackageName() + { + return $this->db->settings()->get('icinga_package_name'); + } + + public function getActiveChecksum(Db $conn) + { + $db = $conn->getDbAdapter(); + $stage = $this->getActiveStageName(); + if (! $stage) { + return null; + } + + $query = $db->select()->from( + array('l' => 'director_deployment_log'), + array('checksum' => $conn->dbHexFunc('l.config_checksum')) + )->where('l.stage_name = ?', $stage); + + return $db->fetchOne($query); + } + + protected function getDirectorObjects($type, $plural, $map) + { + $attrs = array_merge( + array_keys($map), + array('package', 'templates', 'active') + ); + + $objects = array(); + $result = $this->getObjects($plural, $attrs, $this->getPackageName()); + foreach ($result as $name => $row) { + $attrs = $row->attrs; + + $properties = array( + 'object_name' => $name, + 'object_type' => 'external_object' + ); + + foreach ($map as $key => $prop) { + if (property_exists($attrs, $key)) { + $properties[$prop] = $attrs->$key; + } + } + + $objects[$name] = IcingaObject::createByType($type, $properties, $this->db); + } + + return $objects; + } + + /** + * @return IcingaZone[] + */ + public function getZoneObjects() + { + return $this->getDirectorObjects('Zone', 'zones', [ + 'parent' => 'parent', + 'global' => 'is_global', + ]); + } + + public function getUserObjects() + { + return $this->getDirectorObjects('User', 'users', [ + 'display_name' => 'display_name', + 'email' => 'email', + 'groups' => 'groups', + 'vars' => 'vars', + ]); + } + + protected function buildEndpointZoneMap() + { + $zones = $this->getObjects('zones', ['endpoints'], $this->getPackageName()); + $zoneMap = array(); + + foreach ($zones as $name => $zone) { + if (! is_array($zone->attrs->endpoints)) { + continue; + } + foreach ($zone->attrs->endpoints as $endpoint) { + $zoneMap[$endpoint] = $name; + } + } + + return $zoneMap; + } + + public function getEndpointObjects() + { + $zoneMap = $this->buildEndpointZoneMap(); + $objects = $this->getDirectorObjects('Endpoint', 'endpoints', [ + 'host' => 'host', + 'port' => 'port', + 'log_duration' => 'log_duration', + ]); + + foreach ($objects as $object) { + $name = $object->object_name; + if (array_key_exists($name, $zoneMap)) { + $object->zone = $zoneMap[$name]; + } + } + + return $objects; + } + + public function getHostObjects() + { + $params = [ + 'display_name' => 'display_name', + 'address' => 'address', + 'address6' => 'address6', + 'templates' => 'imports', + 'groups' => 'groups', + 'vars' => 'vars', + 'check_command' => 'check_command', + 'max_check_attempts' => 'max_check_attempts', + 'check_period' => 'check_period', + 'check_interval' => 'check_interval', + 'retry_interval' => 'retry_interval', + 'enable_notifications' => 'enable_notifications', + 'enable_active_checks' => 'enable_active_checks', + 'enable_passive_checks' => 'enable_passive_checks', + 'enable_event_handler' => 'enable_event_handler', + 'enable_flapping' => 'enable_flapping', + 'enable_perfdata' => 'enable_perfdata', + 'event_command' => 'event_command', + 'volatile' => 'volatile', + 'zone' => 'zone', + 'command_endpoint' => 'command_endpoint', + 'notes' => 'notes', + 'notes_url' => 'notes_url', + 'action_url' => 'action_url', + 'icon_image' => 'icon_image', + 'icon_image_alt' => 'icon_image_alt', + ]; + + if (version_compare($this->getVersion(), '2.8.0', '>=')) { + $params['flapping_threshold_high'] = 'flapping_threshold_high'; + $params['flapping_threshold_low'] = 'flapping_threshold_low'; + } + + return $this->getDirectorObjects('Host', 'hosts', $params); + } + + public function getHostGroupObjects() + { + return $this->getDirectorObjects('HostGroup', 'hostgroups', [ + 'display_name' => 'display_name', + ]); + } + + public function getUserGroupObjects() + { + return $this->getDirectorObjects('UserGroup', 'usergroups', [ + 'display_name' => 'display_name', + ]); + } + + /** + * @return IcingaCommand[] + */ + public function getCheckCommandObjects() + { + return $this->getSpecificCommandObjects('Check'); + } + + /** + * @return IcingaCommand[] + */ + public function getNotificationCommandObjects() + { + return $this->getSpecificCommandObjects('Notification'); + } + + /** + * @return IcingaCommand[] + */ + public function getEventCommandObjects() + { + return $this->getSpecificCommandObjects('Event'); + } + + /** + * @return IcingaCommand[] + */ + public function getSpecificCommandObjects($type) + { + IcingaCommand::setPluginDir($this->getConstant('PluginDir')); + + $objects = $this->getDirectorObjects('Command', "${type}Commands", [ + 'arguments' => 'arguments', + // 'env' => 'env', + 'timeout' => 'timeout', + 'command' => 'command', + 'vars' => 'vars', + ]); + foreach ($objects as $obj) { + $obj->methods_execute = "Plugin$type"; + } + + return $objects; + } + + public function listPackageStages($name, $active = null) + { + $packages = $this->getPackages(); + $found = array(); + + if (array_key_exists($name, $packages)) { + $package = $packages[$name]; + $current = $package->{'active-stage'}; + foreach ($package->stages as $stage) { + if ($active === null) { + $found[] = $stage; + } elseif ($active === true) { + if ($current === $stage) { + $found[] = $stage; + } + } elseif ($active === false) { + if ($current !== $stage) { + $found[] = $stage; + } + } + } + } + + return $found; + } + + public function collectLogFiles(Db $db) + { + $existing = $this->listPackageStages($this->getPackageName()); + $missing = []; + $empty = []; + foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) { + $stage = $deployment->get('stage_name'); + if (! in_array($stage, $existing)) { + $missing[] = $deployment; + continue; + } + + try { + $availableFiles = $this->listStageFiles($stage); + } catch (Exception $e) { + // Could not collect stage files. Doesn't matter, let's try next time + continue; + } + + if (in_array('startup.log', $availableFiles) + && in_array('status', $availableFiles) + ) { + if ($this->getStagedFile($stage, 'status') === '0') { + $deployment->set('startup_succeeded', 'y'); + } else { + $deployment->set('startup_succeeded', 'n'); + } + $deployment->set('startup_log', $this->shortenStartupLog( + $this->getStagedFile($stage, 'startup.log') + )); + } else { + // Stage seems to be incomplete, let's try again next time + $empty[] = $deployment; + continue; + } + $deployment->set('stage_collected', 'y'); + + $deployment->store(); + + /** @var DeploymentHook[] $hooks */ + $hooks = Hook::all('director/Deployment'); + foreach ($hooks as $hook) { + $hook->onCollect($deployment); + } + } + + foreach ($missing as $deployment) { + $deployment->set('stage_collected', 'n'); + $deployment->store(); + } + + $running = DirectorDeploymentLog::getRelatedToActiveStage($this, $db); + if ($running !== null) { + foreach ($empty as $deployment) { + if ($deployment->getDeploymentTimestamp() < $running->getDeploymentTimestamp()) { + $deployment->set('stage_collected', 'n'); + $deployment->store(); + $this->deleteStage($this->getPackageName(), $deployment->get('stage_name')); + } + } + } + } + + public function wipeInactiveStages(Db $db) + { + $uncollected = DirectorDeploymentLog::getUncollected($db); + $packageName = $this->getPackageName(); + foreach ($this->listPackageStages($packageName, false) as $stage) { + if (array_key_exists($stage, $uncollected)) { + continue; + } + $this->client()->delete($this->prepareStageUrl($packageName, $stage)); + } + } + + public function listStageFiles($stage, $packageName = null) + { + if ($packageName === null) { + $packageName = $this->getPackageName(); + } + return array_keys( + $this->client() + ->get($this->prepareStageUrl($packageName, $stage)) + ->getResult('name', ['type' => 'file']) + ); + } + + public function getStagedFile($stage, $file, $packageName = null) + { + if ($packageName === null) { + $packageName = $this->getPackageName(); + } + return $this->client() + ->getRaw($this->prepareFileUrl($packageName, $stage, $file)); + } + + public function hasPackage($name) + { + $modules = $this->getPackages(); + return \array_key_exists($name, $modules); + } + + public function createPackage($name) + { + return $this->client()->post($this->preparePackageUrl($name))->succeeded(); + } + + public function deletePackage($name) + { + return $this->client()->delete($this->preparePackageUrl($name))->succeeded(); + } + + public function assertPackageExists($name) + { + if (! $this->hasPackage($name)) { + if (! $this->createPackage($name)) { + throw new RuntimeException(sprintf( + 'Failed to create the package "%s" through the REST API', + $name + )); + } + } + + return $this; + } + + public function deleteStage($packageName, $stageName) + { + $this->client()->delete( + $this->prepareStageUrl($packageName, $stageName) + )->succeeded(); + } + + /** + * @throws Exception + */ + public function stream() + { + $allTypes = array( + 'CheckResult', + 'StateChange', + 'Notification', + 'AcknowledgementSet', + 'AcknowledgementCleared', + 'CommentAdded', + 'CommentRemoved', + 'DowntimeAdded', + 'DowntimeRemoved', + 'DowntimeTriggered' + ); + + $queue = 'director-rand'; + + $url = sprintf('events?queue=%s&types=%s', $queue, implode('&types=', $allTypes)); + + $this->client()->request('post', $url, null, false, true); + } + + /** + * @param IcingaConfig $config + * @param Db $db + * @param null $packageName + * @return DirectorDeploymentLog + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null) + { + if ($packageName === null) { + $packageName = $db->settings()->get('icinga_package_name'); + } + $start = microtime(true); + /** @var DirectorDeploymentLog $deployment */ + $deployment = DirectorDeploymentLog::create(array( + // 'config_id' => $config->id, + // 'peer_identity' => $endpoint->object_name, + 'peer_identity' => $this->client->getPeerIdentity(), + 'start_time' => date('Y-m-d H:i:s'), + 'config_checksum' => $config->getChecksum(), + 'last_activity_checksum' => $config->getLastActivityChecksum() + // 'triggered_by' => Util::getUsername(), + // 'username' => Util::getUsername(), + // 'module_name' => $moduleName, + )); + + /** @var DeploymentHook[] $hooks */ + $hooks = Hook::all('director/Deployment'); + foreach ($hooks as $hook) { + $hook->beforeDeploy($deployment); + } + + $this->assertPackageExists($packageName); + + $response = $this->client()->post('config/stages/' . \rawurlencode($packageName), [ + 'files' => $config->getFileContents() + ]); + + $duration = (int) ((microtime(true) - $start) * 1000); + // $deployment->duration_ms = $duration; + $deployment->set('duration_dump', $duration); + + $succeeded = 'n'; + if ($response->succeeded()) { + if ($stage = $response->getResult('stage', ['package' => $packageName])) { // Status? + $deployment->set('stage_name', key($stage)); + $succeeded = 'y'; + } + } + $deployment->set('dump_succeeded', $succeeded); + $deployment->store($db); + + if ($succeeded === 'y') { + foreach ($hooks as $hook) { + $hook->triggerSuccessfulDump($deployment); + } + } + + return $deployment; + } + + protected function shortenStartupLog($log) + { + $logLen = strlen($log); + if ($logLen < 1024 * 60) { + return $log; + } + + $part = substr($log, 0, 1024 * 20); + $parts = explode("\n", $part); + array_pop($parts); + $begin = implode("\n", $parts) . "\n\n"; + + $part = substr($log, -1024 * 20); + $parts = explode("\n", $part); + array_shift($parts); + $end = "\n\n" . implode("\n", $parts); + + return $begin . sprintf( + '[..] %d bytes removed by Director [..]', + $logLen - (strlen($begin) + strlen($end)) + ) . $end; + } + + protected function preparePackageUrl($packageName) + { + return 'config/packages/' . \rawurlencode($packageName); + } + + protected function prepareStageUrl($packageName, $stage) + { + return \sprintf( + 'config/stages/%s/%s', + \rawurlencode($packageName), + \rawurlencode($stage) + ); + } + + protected function prepareFileUrl($packageName, $stage, $file) + { + return \sprintf( + 'config/files/%s/%s/%s', + \rawurlencode($packageName), + \rawurlencode($stage), + \rawurlencode($file) + ); + } + + protected function client() + { + if ($this->initialized === false) { + $this->initialized = true; + $this->enableWorkaroundForConnectionIssues(); + } + + return $this->client; + } +} diff --git a/library/Director/Core/DeploymentApiInterface.php b/library/Director/Core/DeploymentApiInterface.php new file mode 100644 index 0000000..026f0fd --- /dev/null +++ b/library/Director/Core/DeploymentApiInterface.php @@ -0,0 +1,75 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; + +/** + * Interface to a deployment API of the monitoring configuration + * + * @package Icinga\Module\Director\Core + */ +interface DeploymentApiInterface +{ + /** + * Collecting log files from the deployment system + * and write them into the database. + * + * @param Db $db + */ + public function collectLogFiles(Db $db); + + /** + * Cleanup old stages that are collected and not active + * + * @param Db $db + */ + public function wipeInactiveStages(Db $db); + + /** + * Returns the active configuration stage + * + * @return string + */ + public function getActiveStageName(); + + /** + * List files in a named stage + * + * @param string $stage name of the stage + * @return string[] + */ + public function listStageFiles($stage); + + /** + * Retrieve a raw file from the named stage + * + * @param string $stage Stage name + * @param string $file Relative file path + * + * @return string + */ + public function getStagedFile($stage, $file); + + /** + * Explicitly delete a stage + * + * @param string $packageName + * @param string $stageName + * + * @return bool + */ + public function deleteStage($packageName, $stageName); + + /** + * Deploy the config and activate it + * + * @param IcingaConfig $config + * @param Db $db + * @param string $packageName + * + * @return mixed + */ + public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null); +} diff --git a/library/Director/Core/Json.php b/library/Director/Core/Json.php new file mode 100644 index 0000000..507349c --- /dev/null +++ b/library/Director/Core/Json.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Icinga\Module\Director\Exception\JsonEncodeException; + +class Json +{ + public static function encode($mixed, $flags = null) + { + if ($flags === null) { + $result = \json_encode($mixed); + } else { + $result = \json_encode($mixed, $flags); + } + + if ($result === false && json_last_error() !== JSON_ERROR_NONE) { + throw JsonEncodeException::forLastJsonError(); + } + + return $result; + } + + public static function decode($string) + { + $result = \json_decode($string); + + if ($result === null && json_last_error() !== JSON_ERROR_NONE) { + throw JsonEncodeException::forLastJsonError(); + } + + return $result; + } +} diff --git a/library/Director/Core/LegacyDeploymentApi.php b/library/Director/Core/LegacyDeploymentApi.php new file mode 100644 index 0000000..7287c4a --- /dev/null +++ b/library/Director/Core/LegacyDeploymentApi.php @@ -0,0 +1,466 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; + +/** + * Legacy DeploymentApi for Icinga 1.x configuration deployment + * + * @package Icinga\Module\Director\Core + */ +class LegacyDeploymentApi implements DeploymentApiInterface +{ + protected $db; + protected $deploymentPath; + protected $activationScript; + + protected $dir_mode; + protected $file_mode; + + public function __construct(Db $db) + { + $this->db = $db; + $settings = $this->db->settings(); + $this->deploymentPath = $settings->deployment_path_v1; + $this->activationScript = $settings->activation_script_v1; + + $this->dir_mode = base_convert($settings->get('deployment_file_mode_v1', '2775'), 8, 10); + $this->file_mode = base_convert($settings->get('deployment_dir_mode_v1', '0664'), 8, 10); + } + + /** + * TODO: merge in common class + * @inheritdoc + */ + public function collectLogFiles(Db $db) + { + $packageName = $db->settings()->get('icinga_package_name'); + $existing = $this->listPackageStages($packageName); + + foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) { + $stage = $deployment->get('stage_name'); + if (! in_array($stage, $existing)) { + continue; + } + + try { + $availableFiles = $this->listStageFiles($stage); + } catch (Exception $e) { + // Could not collect stage files. Doesn't matter, let's try next time + continue; + } + + if (in_array('startup.log', $availableFiles) + && in_array('status', $availableFiles) + ) { + $status = $this->getStagedFile($stage, 'status'); + $status = trim($status); + if ($status === '0') { + $deployment->set('startup_succeeded', 'y'); + } else { + $deployment->set('startup_succeeded', 'n'); + } + $deployment->set('startup_log', $this->shortenStartupLog( + $this->getStagedFile($stage, 'startup.log') + )); + } else { + // Stage seems to be incomplete, let's try again next time + continue; + } + $deployment->set('stage_collected', 'y'); + + $deployment->store(); + } + } + + /** + * TODO: merge in common class + * @inheritdoc + */ + public function wipeInactiveStages(Db $db) + { + $uncollected = DirectorDeploymentLog::getUncollected($db); + $packageName = $db->settings()->get('icinga_package_name'); + $currentStage = $this->getActiveStageName(); + + // try to expire old deployments + foreach ($uncollected as $name => $deployment) { + /** @var DirectorDeploymentLog $deployment */ + if ($deployment->get('dump_succeeded') === 'n' + || $deployment->get('startup_succeeded') === null + ) { + $start_time = strtotime($deployment->start_time); + + // older than an hour and no startup + if ($start_time + 3600 < time()) { + $deployment->set('startup_succeeded', 'n'); + $deployment->set('startup_log', 'Activation timed out...'); + $deployment->store(); + } + } + } + + foreach ($this->listPackageStages($packageName) as $stage) { + if (array_key_exists($stage, $uncollected) + && $uncollected[$stage]->get('startup_succeeded') === null + ) { + continue; + } elseif ($stage === $currentStage) { + continue; + } else { + $this->deleteStage($packageName, $stage); + } + } + } + + /** @inheritdoc */ + public function getActiveStageName() + { + $this->assertDeploymentPath(); + + $path = $this->deploymentPath . DIRECTORY_SEPARATOR . 'active'; + + if (file_exists($path)) { + if (is_link($path)) { + $linkTarget = readlink($path); + $linkTargetDir = dirname($linkTarget); + $linkTargetName = basename($linkTarget); + + if ($linkTargetDir === $this->deploymentPath || $linkTargetDir === '.') { + return $linkTargetName; + } else { + throw new IcingaException( + 'Active stage link pointing to a invalid target: %s -> %s', + $path, + $linkTarget + ); + } + } else { + throw new IcingaException('Active stage is not a symlink: %s', $path); + } + } else { + return false; + } + } + + /** @inheritdoc */ + public function listStageFiles($stage) + { + $path = $this->getStagePath($stage); + if (! is_dir($path)) { + throw new IcingaException('Deployment stage "%s" does not exist at: %s', $stage, $path); + } + return $this->listDirectoryContents($path); + } + + /** @inheritdoc */ + public function listPackageStages($packageName) + { + $this->assertPackageName($packageName); + $this->assertDeploymentPath(); + + $dh = @opendir($this->deploymentPath); + if ($dh === null) { + throw new IcingaException('Can not list contents of %s', $this->deploymentPath); + } + + $stages = array(); + while ($file = readdir($dh)) { + if ($file === '.' || $file === '..') { + continue; + } elseif (is_dir($this->deploymentPath . DIRECTORY_SEPARATOR . $file) + && substr($file, 0, 9) === 'director-' + ) { + $stages[] = $file; + } + } + + return $stages; + } + + /** @inheritdoc */ + public function getStagedFile($stage, $file) + { + $path = $this->getStagePath($stage); + + $filePath = $path . DIRECTORY_SEPARATOR . $file; + + if (! file_exists($filePath)) { + throw new IcingaException('Could not find file %s', $filePath); + } else { + return file_get_contents($filePath); + } + } + + /** @inheritdoc */ + public function deleteStage($packageName, $stageName) + { + $this->assertPackageName($packageName); + $this->assertDeploymentPath(); + + $path = $this->getStagePath($stageName); + + static::rrmdir($path); + } + + /** @inheritdoc */ + public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null) + { + if ($packageName === null) { + $packageName = $db->settings()->get('icinga_package_name'); + } + $this->assertPackageName($packageName); + $this->assertDeploymentPath(); + + $start = microtime(true); + $deployment = DirectorDeploymentLog::create(array( + // 'config_id' => $config->id, + // 'peer_identity' => $endpoint->object_name, + 'peer_identity' => $this->deploymentPath, + 'start_time' => date('Y-m-d H:i:s'), + 'config_checksum' => $config->getChecksum(), + 'last_activity_checksum' => $config->getLastActivityChecksum() + // 'triggered_by' => Util::getUsername(), + // 'username' => Util::getUsername(), + // 'module_name' => $moduleName, + )); + + $stage_name = 'director-' .date('Ymd-His'); + $deployment->set('stage_name', $stage_name); + + try { + $succeeded = $this->deployStage($stage_name, $config->getFileContents()); + if ($succeeded === true) { + $succeeded = $this->activateStage($stage_name); + } + } catch (Exception $e) { + $deployment->set('dump_succeeded', 'n'); + $deployment->set('startup_log', $e->getMessage()); + $deployment->set('startup_succeeded', 'n'); + $deployment->store($db); + throw $e; + } + + $duration = (int) ((microtime(true) - $start) * 1000); + $deployment->set('duration_dump', $duration); + + $deployment->set('dump_succeeded', $succeeded === true ? 'y' : 'n'); + + $deployment->store($db); + return $succeeded; + } + + /** + * Deploy a new stage, and write all files to it + * + * @param string $stage Name of the stage + * @param array $files Array of files, $fileName => $content + * + * @return bool Success status + * + * @throws IcingaException When something could not be accessed + */ + protected function deployStage($stage, $files) + { + $path = $this->deploymentPath . DIRECTORY_SEPARATOR . $stage; + + if (file_exists($path)) { + throw new IcingaException('Stage "%s" does already exist at: ', $stage, $path); + } else { + $this->mkdir($path); + + foreach ($files as $file => $content) { + $fullPath = $path . DIRECTORY_SEPARATOR . $file; + $this->mkdir(dirname($fullPath), true); + + $fh = @fopen($fullPath, 'w'); + if ($fh === null) { + throw new IcingaException('Could not open file "%s" for writing.', $fullPath); + } + chmod($fullPath, $this->file_mode); + + fwrite($fh, $content); + fclose($fh); + } + + return true; + } + } + + /** + * Starts activation of + * + * Note: script should probably fork to background? + * + * @param string $stage Stage to activate + * + * @return bool + * + * @throws IcingaException For an execution error + */ + protected function activateStage($stage) + { + if ($this->activationScript === null || trim($this->activationScript) === '') { + // skip activation, could be done by external cron worker + return true; + } else { + $command = sprintf('%s %s 2>&1', escapeshellcmd($this->activationScript), escapeshellarg($stage)); + $output = null; + $rc = null; + exec($command, $output, $rc); + $output = join("\n", $output); + if ($rc !== 0) { + throw new IcingaException("Activation script did exit with return code %d:\n\n%s", $rc, $output); + } + return true; + } + } + + /** + * Recursively dump directory contents, with relative path + * + * @param string $path Absolute path to read from + * @param int $depth Internal counter + * + * @return string[] + * + * @throws IcingaException When directory could not be opened + */ + protected function listDirectoryContents($path, $depth = 0) + { + $dh = @opendir($path); + if ($dh === null) { + throw new IcingaException('Can not list contents of %s', $path); + } + + $files = array(); + while ($file = readdir($dh)) { + $fullPath = $path . DIRECTORY_SEPARATOR . $file; + if ($file === '.' || $file === '..') { + continue; + } elseif (is_dir($fullPath)) { + $subdirFiles = $this->listDirectoryContents($fullPath, $depth + 1); + foreach ($subdirFiles as $subFile) { + $files[] = $file . DIRECTORY_SEPARATOR . $subFile; + } + } else { + $files[] = $file; + } + } + + if ($depth === 0) { + sort($files); + } + + return $files; + } + + /** + * Assert that only the director module is interacted with + * + * @param string $packageName + * @throws IcingaException When another module is requested + */ + protected function assertPackageName($packageName) + { + if ($packageName !== 'director') { + throw new IcingaException('Does not supported different modules!'); + } + } + + /** + * Assert the deployment path to be configured, existing, and writeable + * + * @throws IcingaException + */ + protected function assertDeploymentPath() + { + if ($this->deploymentPath === null) { + throw new IcingaException('Deployment path is not configured for legacy config!'); + } elseif (! is_dir($this->deploymentPath)) { + throw new IcingaException('Deployment path is not a directory: %s', $this->deploymentPath); + } elseif (! is_writeable($this->deploymentPath)) { + throw new IcingaException('Deployment path is not a writeable: %s', $this->deploymentPath); + } + } + + /** + * TODO: avoid code duplication: copied from CoreApi + * + * @param string $log The log contents to shorten + * @return string + */ + protected function shortenStartupLog($log) + { + $logLen = strlen($log); + if ($logLen < 1024 * 60) { + return $log; + } + + $part = substr($log, 0, 1024 * 20); + $parts = explode("\n", $part); + array_pop($parts); + $begin = implode("\n", $parts) . "\n\n"; + + $part = substr($log, -1024 * 20); + $parts = explode("\n", $part); + array_shift($parts); + $end = "\n\n" . implode("\n", $parts); + + return $begin . sprintf( + '[..] %d bytes removed by Director [..]', + $logLen - (strlen($begin) + strlen($end)) + ) . $end; + } + + /** + * Return the full path of a stage + * + * @param string $stage Name of the stage + * + * @return string + */ + public function getStagePath($stage) + { + $this->assertDeploymentPath(); + return $this->deploymentPath . DIRECTORY_SEPARATOR . $stage; + } + + /** + * @from https://php.net/manual/de/function.rmdir.php#108113 + * @param $dir + */ + protected static function rrmdir($dir) + { + foreach (glob($dir . '/*') as $file) { + if (is_dir($file)) { + static::rrmdir($file); + } else { + unlink($file); + } + } + + rmdir($dir); + } + + protected function mkdir($path, $recursive = false) + { + if (! file_exists($path)) { + if ($recursive) { + $this->mkdir(dirname($path)); + } + + try { + mkdir($path); + chmod($path, $this->dir_mode); + } catch (Exception $e) { + throw new IcingaException('Could not create path "%s": %s', $path, $e->getMessage()); + } + } + } +} diff --git a/library/Director/Core/RestApiClient.php b/library/Director/Core/RestApiClient.php new file mode 100644 index 0000000..b0854ff --- /dev/null +++ b/library/Director/Core/RestApiClient.php @@ -0,0 +1,276 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Icinga\Application\Benchmark; +use RuntimeException; + +class RestApiClient +{ + protected $version = 'v1'; + + protected $peer; + + protected $port; + + protected $user; + + protected $pass; + + protected $curl; + + protected $readBuffer = ''; + + protected $onEvent; + + protected $onEventWantsRaw; + + protected $keepAlive = true; + + public function __construct($peer, $port = 5665, $cn = null) + { + $this->peer = $peer; + $this->port = $port; + } + + // TODO: replace with Web2 CA trust resource plus cert and get rid + // of user/pass or at least strongly advise against using it + public function setCredentials($user, $pass) + { + $this->user = $user; + $this->pass = $pass; + + return $this; + } + + public function onEvent($callback, $raw = false) + { + $this->onEventWantsRaw = $raw; + $this->onEvent = $callback; + + return $this; + } + + public function getPeerIdentity() + { + return $this->peer; + } + + public function setKeepAlive($keepAlive = true) + { + $this->keepAlive = (bool) $keepAlive; + + return $this; + } + + protected function url($url) + { + return sprintf('https://%s:%d/%s/%s', $this->peer, $this->port, $this->version, $url); + } + + /** + * @param $method + * @param $url + * @param null $body + * @param bool $raw + * @param bool $stream + * @return RestApiResponse + */ + public function request($method, $url, $body = null, $raw = false, $stream = false) + { + if (function_exists('curl_version')) { + return $this->curlRequest($method, $url, $body, $raw, $stream); + } else { + throw new RuntimeException( + 'No CURL extension detected, it must be installed and enabled' + ); + } + } + + protected function curlRequest($method, $url, $body = null, $raw = false, $stream = false) + { + $auth = sprintf('%s:%s', $this->user, $this->pass); + $headers = [ + 'Host: ' . $this->getPeerIdentity(), + ]; + + if (! $this->keepAlive) { + // This fails on Icinga 2.9: + // $headers[] = 'Connection: close'; + } + + if (! $raw) { + $headers[] = 'Accept: application/json'; + } + + if ($body !== null) { + $body = Json::encode($body); + $headers[] = 'Content-Type: application/json'; + } + + $curl = $this->curl(); + $opts = [ + CURLOPT_URL => $this->url($url), + CURLOPT_HTTPHEADER => $headers, + CURLOPT_USERPWD => $auth, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + + // TODO: Fix this! + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_SSL_VERIFYPEER => false, + ]; + + if ($body !== null) { + $opts[CURLOPT_POSTFIELDS] = $body; + } + + if ($stream) { + $opts[CURLOPT_WRITEFUNCTION] = [$this, 'readPart']; + $opts[CURLOPT_TCP_NODELAY] = 1; + } + + curl_setopt_array($curl, $opts); + // TODO: request headers, validate status code + + Benchmark::measure('Rest Api, sending ' . $url); + $res = curl_exec($curl); + if ($res === false) { + $error = curl_error($curl); + $this->disconnect(); + + throw new RuntimeException("CURL ERROR: $error"); + } + + $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + if ($statusCode === 401) { + $this->disconnect(); + throw new RuntimeException( + 'Unable to authenticate, please check your API credentials' + ); + } + + Benchmark::measure('Rest Api, got response'); + if (! $this->keepAlive) { + $this->disconnect(); + } + + if ($stream) { + return $this; + } + + if ($raw) { + return $res; + } else { + return RestApiResponse::fromJsonResult($res); + } + } + + /** + * @param resource $curl + * @param $data + * @return int + */ + protected function readPart($curl, &$data) + { + $length = strlen($data); + $this->readBuffer .= $data; + $this->processEvents(); + return $length; + } + + public function get($url, $body = null) + { + return $this->request('get', $url, $body); + } + + public function getRaw($url, $body = null) + { + return $this->request('get', $url, $body, true); + } + + public function post($url, $body = null) + { + return $this->request('post', $url, $body); + } + + public function put($url, $body = null) + { + return $this->request('put', $url, $body); + } + + public function delete($url, $body = null) + { + return $this->request('delete', $url, $body); + } + + /** + * @return resource + */ + protected function curl() + { + if ($this->curl === null) { + $this->curl = curl_init(sprintf('https://%s:%d', $this->peer, $this->port)); + if (! $this->curl) { + throw new RuntimeException('CURL INIT ERROR: ' . curl_error($this->curl)); + } + } + + return $this->curl; + } + + protected function processEvents() + { + $offset = 0; + while (false !== ($pos = strpos($this->readBuffer, "\n", $offset))) { + if ($pos === $offset) { + // echo "Got empty line $offset / $pos\n"; + $offset = $pos + 1; + continue; + } + $this->processReadBuffer($offset, $pos); + + $offset = $pos + 1; + } + + if ($offset > 0) { + $this->readBuffer = substr($this->readBuffer, $offset + 1); + } + + // echo "REMAINING: " . strlen($this->readBuffer) . "\n"; + } + + protected function processReadBuffer($offset, $pos) + { + if ($this->onEvent === null) { + return; + } + + $func = $this->onEvent; + $str = substr($this->readBuffer, $offset, $pos); + // printf("Processing %s bytes\n", strlen($str)); + + if ($this->onEventWantsRaw) { + $func($str); + } else { + $func(Json::decode($str)); + } + } + + public function disconnect() + { + if ($this->curl !== null) { + if (is_resource($this->curl)) { + @curl_close($this->curl); + } + + $this->curl = null; + } + } + + public function __destruct() + { + $this->disconnect(); + } +} diff --git a/library/Director/Core/RestApiResponse.php b/library/Director/Core/RestApiResponse.php new file mode 100644 index 0000000..523ed35 --- /dev/null +++ b/library/Director/Core/RestApiResponse.php @@ -0,0 +1,149 @@ +<?php + +namespace Icinga\Module\Director\Core; + +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; + +class RestApiResponse +{ + protected $errorMessage; + + protected $results; + + protected function __construct() + { + } + + public static function fromJsonResult($json) + { + $response = new static; + return $response->parseJsonResult($json); + } + + public static function fromErrorMessage($error) + { + $response = new static; + $response->errorMessage = $error; + + return $response; + } + + public function getResult($desiredKey, $filter = array()) + { + return $this->extractResult($this->results, $desiredKey, $filter); + } + + public function getRaw($key = null, $default = null) + { + if ($key === null) { + return $this->results; + } elseif (isset($this->results[0]) && property_exists($this->results[0], $key)) { + return $this->results[0]->$key; + } else { + return $default; + } + } + + public function getSingleResult() + { + if ($this->isErrorCode($this->results[0]->code)) { + throw new IcingaException( + $this->results[0]->status + ); + } else { + return $this->results[0]->result; + } + } + + protected function isErrorCode($code) + { + $code = (int) ceil($code); + return $code >= 400; + } + + protected function extractResult($results, $desiredKey, $filter = array()) + { + $response = array(); + foreach ($results as $result) { + foreach ($filter as $key => $val) { + if (! property_exists($result, $key)) { + continue; + } + if ($result->$key !== $val) { + continue; + } + } + if (! property_exists($result, $desiredKey)) { + continue; + } + + $response[$result->$desiredKey] = $result; + } + return $response; + } + + public function getErrorMessage() + { + return $this->errorMessage; + } + + public function succeeded() + { + return $this->errorMessage === null; + } + + protected function parseJsonResult($json) + { + $result = @json_decode($json); + if ($result === null) { + $this->setJsonError(); + // <h1>Bad Request</h1><p><pre>bad version</pre></p> + throw new IcingaException( + 'Parsing JSON result failed: ' + . $this->errorMessage + . ' (Got: ' . substr($json, 0, 60) . ')' + ); + } + if (property_exists($result, 'error')) { + if (property_exists($result, 'status')) { + if ((int) $result->error === 404) { + throw new NotFoundError($result->status); + } else { + throw new IcingaException('API request failed: ' . $result->status); + } + } else { + throw new IcingaException('API request failed: ' . var_export($result, 1)); + } + } + + $this->results = $result->results; // TODO: Check if set + return $this; + } + + // TODO: just return json_last_error_msg() for PHP >= 5.5.0 + protected function setJsonError() + { + switch (json_last_error()) { + case JSON_ERROR_DEPTH: + $this->errorMessage = 'The maximum stack depth has been exceeded'; + break; + case JSON_ERROR_CTRL_CHAR: + $this->errorMessage = 'Control character error, possibly incorrectly encoded'; + break; + case JSON_ERROR_STATE_MISMATCH: + $this->errorMessage = 'Invalid or malformed JSON'; + break; + case JSON_ERROR_SYNTAX: + $this->errorMessage = 'Syntax error'; + break; + case JSON_ERROR_UTF8: + $this->errorMessage = 'Malformed UTF-8 characters, possibly incorrectly encoded'; + break; + default: + $this->errorMessage = 'An error occured when parsing a JSON string'; + } + + return $this; + } +} diff --git a/library/Director/CoreBeta/ApiStream.php b/library/Director/CoreBeta/ApiStream.php new file mode 100644 index 0000000..478fd40 --- /dev/null +++ b/library/Director/CoreBeta/ApiStream.php @@ -0,0 +1,57 @@ +<?php + +namespace Icinga\Module\Director\CoreBeta; + +use Exception; + +class ApiClient extends Stream +{ + protected $port; + + public static function create($peer, $port = 5665) + { + $stream = new static(); + } + + protected function createClientConnection() + { + $context = $this->createSslContext(); + if ($context === false) { + echo "Unable to set SSL options\n"; + return false; + } + + $conn = stream_socket_client( + 'ssl://' . $this->peername . ':' . $this->peerport, + $errno, + $errstr, + 15, + STREAM_CLIENT_CONNECT, + $context + ); + + return $conn; + } + + protected function createSslContext() + { + $local = 'ssl://' . $this->local; + $context = stream_context_create(); + + // Hack, we need key and cert: + $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi'; + + $options = array( + 'ssl' => array( + 'verify_host' => true, + 'cafile' => $this->ssldir . '/ca.crt', + 'local_cert' => $this->ssldir . '/' . $certfile, + 'CN_match' => 'monitor1', + ) + ); + + $result = stream_context_set_option($context, $options); + + return $context; + } +} diff --git a/library/Director/CoreBeta/Stream.php b/library/Director/CoreBeta/Stream.php new file mode 100644 index 0000000..5add9a3 --- /dev/null +++ b/library/Director/CoreBeta/Stream.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\CoreBeta; + +abstract class Stream +{ + protected $stream; + + protected $buffer = ''; + + protected $bufferLength = 0; + + protected function __construct($stream) + { + $this->stream = $stream; + } +} diff --git a/library/Director/CoreBeta/StreamContext.php b/library/Director/CoreBeta/StreamContext.php new file mode 100644 index 0000000..4844b79 --- /dev/null +++ b/library/Director/CoreBeta/StreamContext.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\CoreBeta; + +use Icinga\Exception\ProgrammingError; + +class StreamContext +{ + protected $options = array(); + + public function ssl() + { + if ($this->ssl === null) { + $this->ssl = new StreamContextSslOptions(); + } + + return $this->ssl; + } + + public function isSsl() + { + return $this->ssl !== null; + } + + public function setCA(CA $ca) + { + // $this->options + } + + protected function createSslContext() + { + $local = 'ssl://' . $this->local; + $context = stream_context_create(); + + // Hack, we need key and cert: + $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi'; + + $options = array( + 'ssl' => array( + 'verify_host' => true, + 'cafile' => $this->ssldir . '/ca.crt', + 'local_cert' => $this->ssldir . '/' . $certfile, + 'CN_match' => 'monitor1', + ) + ); + + $result = stream_context_set_option($context, $options); + + return $context; + } + + public function setContextOptions($options) + { + if (array_key_exists('ssl', $options)) { + throw new ProgrammingError('Direct access to ssl options is not allowed'); + } + } + + protected function reallySetContextOptions($options) + { + if ($this->context === null) { + $this->options = $options; + } else { + stream_context_set_option($this->context, $options); + } + } + + protected function lazyContext() + { + if ($this->context === null) { + $this->context = stream_context_create(); + $this->setContextOptions($this->getOptions()); + + // stream_context_set_option($this->context + if ($this->isSsl()) { + $this->options['ssl'] = $this->ssl()->getOptions(); + } + + $result = stream_context_set_option($this->context, $this->options); + } + + return $this->context; + } + + public function getRawContext() + { + return $this->lazyContext(); + } +} diff --git a/library/Director/CoreBeta/StreamContextSslOptions.php b/library/Director/CoreBeta/StreamContextSslOptions.php new file mode 100644 index 0000000..d01d4a5 --- /dev/null +++ b/library/Director/CoreBeta/StreamContextSslOptions.php @@ -0,0 +1,52 @@ +<?php + +namespace Icinga\Module\Director\CoreBeta; + +use Icinga\Exception\ProgrammingError; + +class StreamContextSslOptions +{ + protected $options = array( + 'verify_peer' => true, + ); + + public function setCA(CA $ca) + { + $this->ca = $ca; + } + + public function capturePeerCert($capture = true) + { + $this->options['capture_peer_cert'] = (bool) $capture; + return $this; + } + + public function capturePeerChain($capture = true) + { + $this->options['capture_peer_chain'] = (bool) $capture; + return $this; + } + + public function setCiphers($ciphers) + { + $this->options['ciphers'] = $ciphers; + return $this; + } + + public function setPeerName($name) + { + if (version_compare(PHP_VERSION, '5.6.0') >= 0) { + $this->options['peer_name'] = $name; + $this->options['verify_peer_name'] = true; + } else { + $this->options['CN_match'] = $name; + } + return $this; + } + + public function getOptions() + { + // TODO: Fail on missing cert + return $this->options; + } +} diff --git a/library/Director/CustomVariable/CustomVariable.php b/library/Director/CustomVariable/CustomVariable.php new file mode 100644 index 0000000..98eda84 --- /dev/null +++ b/library/Director/CustomVariable/CustomVariable.php @@ -0,0 +1,286 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Exception; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use InvalidArgumentException; +use LogicException; + +abstract class CustomVariable implements IcingaConfigRenderer +{ + protected $key; + + protected $value; + + protected $storedValue; + + protected $type; + + protected $modified = false; + + protected $loadedFromDb = false; + + protected $deleted = false; + + protected $checksum; + + protected function __construct($key, $value = null) + { + $this->key = $key; + $this->setValue($value); + } + + public function is($type) + { + return $this->getType() === $type; + } + + public function getType() + { + if ($this->type === null) { + $parts = explode('\\', get_class($this)); + $class = end($parts); + // strlen('CustomVariable') === 14 + $this->type = substr($class, 14); + } + + return $this->type; + } + + // TODO: implement delete() + public function hasBeenDeleted() + { + return $this->deleted; + } + + public function delete() + { + $this->deleted = true; + return $this; + } + + // TODO: abstract + public function getDbValue() + { + return $this->getValue(); + } + + public function toJson() + { + if ($this->getDbFormat() === 'string') { + return json_encode($this->getDbValue()); + } else { + return $this->getDbValue(); + } + } + + // TODO: abstract + public function getDbFormat() + { + return 'string'; + } + + public function getKey() + { + return $this->key; + } + + /** + * @param $value + * @return $this + */ + abstract public function setValue($value); + + abstract public function getValue(); + + /** + * @param bool $renderExpressions + * @return string + */ + public function toConfigString($renderExpressions = false) + { + // TODO: this should be an abstract method once we deprecate PHP < 5.3.9 + throw new LogicException(sprintf( + '%s has no toConfigString() implementation', + get_class($this) + )); + } + + public function flatten(array &$flat, $prefix) + { + $flat[$prefix] = $this->getDbValue(); + } + + public function render($renderExpressions = false) + { + return c::renderKeyValue( + $this->renderKeyName($this->getKey()), + $this->toConfigStringPrefetchable($renderExpressions) + ); + } + + protected function renderKeyName($key) + { + if (preg_match('/^[a-z][a-z0-9_]*$/i', $key)) { + return 'vars.' . c::escapeIfReserved($key); + } else { + return 'vars[' . c::renderString($key) . ']'; + } + } + + public function checksum() + { + // TODO: remember checksum, invalidate on change + return sha1($this->getKey() . '=' . $this->toJson(), true); + } + + public function isNew() + { + return ! $this->loadedFromDb; + } + + public function hasBeenModified() + { + return $this->modified; + } + + public function toConfigStringPrefetchable($renderExpressions = false) + { + if (PrefetchCache::shouldBeUsed()) { + return PrefetchCache::instance()->renderVar($this, $renderExpressions); + } else { + return $this->toConfigString($renderExpressions); + } + } + + public function setModified($modified = true) + { + $this->modified = $modified; + if (! $this->modified) { + if (is_object($this->value)) { + $this->storedValue = clone($this->value); + } else { + $this->storedValue = $this->value; + } + } + + return $this; + } + + public function setUnmodified() + { + return $this->setModified(false); + } + + public function setLoadedFromDb($loaded = true) + { + $this->loadedFromDb = $loaded; + return $this; + } + + abstract public function equals(CustomVariable $var); + + public function differsFrom(CustomVariable $var) + { + return ! $this->equals($var); + } + + protected function setChecksum($checksum) + { + $this->checksum = $checksum; + return $this; + } + + public function getChecksum() + { + return $this->checksum; + } + + public static function wantCustomVariable($key, $value) + { + if ($value instanceof CustomVariable) { + return $value; + } + + return self::create($key, $value); + } + + public static function create($key, $value) + { + if (is_null($value)) { + return new CustomVariableNull($key, $value); + } + + if (is_bool($value)) { + return new CustomVariableBoolean($key, $value); + } + + if (is_int($value) || is_float($value)) { + return new CustomVariableNumber($key, $value); + } + + if (is_string($value)) { + return new CustomVariableString($key, $value); + } elseif (is_array($value)) { + foreach (array_keys($value) as $k) { + if (! (is_int($k) || ctype_digit($k))) { + return new CustomVariableDictionary($key, $value); + } + } + + return new CustomVariableArray($key, array_values($value)); + } elseif (is_object($value)) { + // TODO: check for specific class/stdClass/interface? + return new CustomVariableDictionary($key, $value); + } else { + throw new LogicException(sprintf('WTF (%s): %s', $key, var_export($value, 1))); + } + } + + public static function fromDbRow($row) + { + switch ($row->format) { + case 'string': + $var = new CustomVariableString($row->varname, $row->varvalue); + break; + case 'json': + $var = self::create($row->varname, json_decode($row->varvalue)); + break; + case 'expression': + throw new InvalidArgumentException( + 'Icinga code expressions are not yet supported' + ); + default: + throw new InvalidArgumentException(sprintf( + '%s is not a supported custom variable format', + $row->format + )); + } + if (property_exists($row, 'checksum')) { + $var->setChecksum($row->checksum); + } + + $var->loadedFromDb = true; + $var->setUnmodified(); + return $var; + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + call_user_func($previousHandler, $e); + die(); + } + } +} diff --git a/library/Director/CustomVariable/CustomVariableArray.php b/library/Director/CustomVariable/CustomVariableArray.php new file mode 100644 index 0000000..7e430a4 --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableArray.php @@ -0,0 +1,100 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; + +class CustomVariableArray extends CustomVariable +{ + /** @var CustomVariable[] */ + protected $value; + + public function equals(CustomVariable $var) + { + if (! $var instanceof CustomVariableArray) { + return false; + } + + return $var->getDbValue() === $this->getDbValue(); + } + + public function getValue() + { + $ret = array(); + foreach ($this->value as $var) { + $ret[] = $var->getValue(); + } + + return $ret; + } + + public function getDbValue() + { + return json_encode($this->getValue()); + } + + public function getDbFormat() + { + return 'json'; + } + + public function setValue($value) + { + $new = array(); + + foreach ($value as $k => $v) { + $new[] = self::wantCustomVariable($k, $v); + } + + $equals = true; + if (is_array($this->value) && count($new) === count($this->value)) { + foreach ($this->value as $k => $v) { + if (! $new[$k]->equals($v)) { + $equals = false; + break; + } + } + } else { + $equals = false; + } + + if (! $equals) { + $this->value = $new; + $this->setModified(); + } + + $this->deleted = false; + + return $this; + } + + public function flatten(array &$flat, $prefix) + { + foreach ($this->value as $k => $v) { + $v->flatten($flat, sprintf('%s[%d]', $prefix, $k)); + } + } + + public function toConfigString($renderExpressions = false) + { + $parts = array(); + foreach ($this->value as $k => $v) { + $parts[] = $v->toConfigString($renderExpressions); + } + + return c::renderEscapedArray($parts); + } + + public function __clone() + { + foreach ($this->value as $key => $value) { + $this->value[$key] = clone($value); + } + } + + public function toLegacyConfigString() + { + return c1::renderArray($this->value); + } +} diff --git a/library/Director/CustomVariable/CustomVariableBoolean.php b/library/Director/CustomVariable/CustomVariableBoolean.php new file mode 100644 index 0000000..9953fae --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableBoolean.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Exception\ProgrammingError; + +class CustomVariableBoolean extends CustomVariable +{ + public function equals(CustomVariable $var) + { + return $var->getValue() === $this->getValue(); + } + + public function getDbFormat() + { + return 'json'; + } + + public function getDbValue() + { + return json_encode($this->getValue()); + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if (! is_bool($value)) { + throw new ProgrammingError( + 'Expected a boolean, got %s', + var_export($value, 1) + ); + } + + $this->value = $value; + $this->deleted = false; + + return $this; + } + + public function toConfigString($renderExpressions = false) + { + return $this->value ? 'true' : 'false'; + } + + public function toLegacyConfigString() + { + return $this->toConfigString(); + } +} diff --git a/library/Director/CustomVariable/CustomVariableDictionary.php b/library/Director/CustomVariable/CustomVariableDictionary.php new file mode 100644 index 0000000..d84be4f --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableDictionary.php @@ -0,0 +1,130 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Countable; + +class CustomVariableDictionary extends CustomVariable implements Countable +{ + /** @var CustomVariable[] */ + protected $value; + + public function equals(CustomVariable $var) + { + if (! $var instanceof CustomVariableDictionary) { + return false; + } + + $myKeys = $this->listKeys(); + $foreignKeys = $var->listKeys(); + if ($myKeys !== $foreignKeys) { + return false; + } + + foreach ($this->value as $key => $value) { + if (! $value->equals($var->getInternalValue($key))) { + return false; + } + } + + return true; + } + + public function getDbFormat() + { + return 'json'; + } + + public function getDbValue() + { + return json_encode($this->getValue()); + } + + public function setValue($value) + { + $new = array(); + + foreach ($value as $key => $val) { + $new[$key] = self::wantCustomVariable($key, $val); + } + + $this->deleted = false; + + // WTF? + if ($this->value === $new) { + return $this; + } + + $this->value = $new; + $this->setModified(); + + return $this; + } + + public function getValue() + { + $ret = (object) array(); + ksort($this->value); + + foreach ($this->value as $key => $var) { + $ret->$key = $var->getValue(); + } + + return $ret; + } + + public function flatten(array &$flat, $prefix) + { + foreach ($this->value as $k => $v) { + $v->flatten($flat, sprintf('%s["%s"]', $prefix, $k)); + } + } + + public function listKeys() + { + $keys = array_keys($this->value); + sort($keys); + return $keys; + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->value); + } + + public function __clone() + { + foreach ($this->value as $key => $value) { + $this->value[$key] = clone($value); + } + } + + public function __get($key) + { + return $this->value[$key]; + } + + public function __isset($key) + { + return array_key_exists($key, $this->value); + } + + public function getInternalValue($key) + { + return $this->value[$key]; + } + + public function toConfigString($renderExpressions = false) + { + // TODO + return c::renderDictionary($this->value); + } + + public function toLegacyConfigString() + { + return c1::renderDictionary($this->value); + } +} diff --git a/library/Director/CustomVariable/CustomVariableNull.php b/library/Director/CustomVariable/CustomVariableNull.php new file mode 100644 index 0000000..f87ccfa --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableNull.php @@ -0,0 +1,52 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Exception\ProgrammingError; + +class CustomVariableNull extends CustomVariable +{ + public function equals(CustomVariable $var) + { + return $var instanceof CustomVariableNull; + } + + public function getValue() + { + return null; + } + + public function getDbValue() + { + return json_encode($this->getValue()); + } + + public function getDbFormat() + { + return 'json'; + } + + public function setValue($value) + { + if (! is_null($value)) { + throw new ProgrammingError( + 'Null can only be null, got %s', + var_export($value, 1) + ); + } + + $this->deleted = false; + + return $this; + } + + public function toConfigString($renderExpressions = false) + { + return 'null'; + } + + public function toLegacyConfigString() + { + return $this->toConfigString(); + } +} diff --git a/library/Director/CustomVariable/CustomVariableNumber.php b/library/Director/CustomVariable/CustomVariableNumber.php new file mode 100644 index 0000000..62838a9 --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableNumber.php @@ -0,0 +1,73 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Exception\ProgrammingError; + +class CustomVariableNumber extends CustomVariable +{ + // Hint: 'F' is intentional, this MUST NOT respect locales + const PRECISION = '%.9F'; + + public function equals(CustomVariable $var) + { + if (! $var instanceof CustomVariableNumber) { + return false; + } + + $cur = $this->getValue(); + $new = $var->getValue(); + + // Be tolerant when comparing floats: + if (is_float($cur) || is_float($new)) { + return sprintf(self::PRECISION, $cur) + === sprintf(self::PRECISION, $new); + } + + return $cur === $new; + } + + public function getDbFormat() + { + return 'json'; + } + + public function getDbValue() + { + return json_encode($this->getValue()); + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if (! is_int($value) && ! is_float($value)) { + throw new ProgrammingError( + 'Expected a number, got %s', + var_export($value, 1) + ); + } + + $this->value = $value; + $this->deleted = false; + + return $this; + } + + public function toConfigString($renderExpressions = false) + { + if (is_int($this->value)) { + return (string) $this->value; + } else { + return sprintf(self::PRECISION, $this->value); + } + } + + public function toLegacyConfigString() + { + return $this->toConfigString(); + } +} diff --git a/library/Director/CustomVariable/CustomVariableString.php b/library/Director/CustomVariable/CustomVariableString.php new file mode 100644 index 0000000..2d50968 --- /dev/null +++ b/library/Director/CustomVariable/CustomVariableString.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; + +class CustomVariableString extends CustomVariable +{ + public function equals(CustomVariable $var) + { + if (! $var instanceof CustomVariableString) { + return false; + } + + return $var->getValue() === $this->getValue(); + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if (! is_string($value)) { + $value = (string) $value; + } + + if ($value !== $this->value) { + $this->value = $value; + $this->setModified(); + } + + $this->deleted = false; + + return $this; + } + + public function flatten(array &$flat, $prefix) + { + // TODO: we should get rid of type=string and always use JSON + $flat[$prefix] = json_encode($this->getValue()); + } + + public function toConfigString($renderExpressions = false) + { + if ($renderExpressions) { + return c::renderStringWithVariables($this->getValue(), ['config']); + } else { + return c::renderString($this->getValue()); + } + } + + public function toLegacyConfigString() + { + return c1::renderString($this->getValue()); + } +} diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php new file mode 100644 index 0000000..cdcc4bd --- /dev/null +++ b/library/Director/CustomVariable/CustomVariables.php @@ -0,0 +1,488 @@ +<?php + +namespace Icinga\Module\Director\CustomVariable; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\Objects\IcingaObject; +use Countable; +use Exception; +use Iterator; + +class CustomVariables implements Iterator, Countable, IcingaConfigRenderer +{ + /** @var CustomVariable[] */ + protected $storedVars = array(); + + /** @var CustomVariable[] */ + protected $vars = array(); + + protected $modified = false; + + private $position = 0; + + private $overrideKeyName; + + protected $idx = array(); + + protected static $allTables = array( + 'icinga_command_var', + 'icinga_host_var', + 'icinga_notification_var', + 'icinga_service_set_var', + 'icinga_service_var', + 'icinga_user_var', + ); + + public static function countAll($varname, Db $connection) + { + $db = $connection->getDbAdapter(); + $parts = array(); + $where = $db->quoteInto('varname = ?', $varname); + foreach (static::$allTables as $table) { + $parts[] = "SELECT COUNT(*) as cnt FROM $table WHERE $where"; + } + + $sub = implode(' UNION ALL ', $parts); + $query = "SELECT SUM(sub.cnt) AS cnt FROM ($sub) sub"; + + return (int) $db->fetchOne($query); + } + + public static function deleteAll($varname, Db $connection) + { + $db = $connection->getDbAdapter(); + $where = $db->quoteInto('varname = ?', $varname); + foreach (static::$allTables as $table) { + $db->delete($table, $where); + } + } + + public static function renameAll($oldname, $newname, Db $connection) + { + $db = $connection->getDbAdapter(); + $where = $db->quoteInto('varname = ?', $oldname); + foreach (static::$allTables as $table) { + $db->update($table, ['varname' => $newname], $where); + } + } + + #[\ReturnTypeWillChange] + public function count() + { + $count = 0; + foreach ($this->vars as $var) { + if (! $var->hasBeenDeleted()) { + $count++; + } + } + + return $count; + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->vars[$this->idx[$this->position]]; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + /** + * Generic setter + * + * @param string $key + * @param mixed $value + * + * @return self + */ + public function set($key, $value) + { + $key = (string) $key; + + if ($value instanceof CustomVariable) { + $value = clone($value); + } else { + if ($value === null) { + $this->__unset($key); + return $this; + } + $value = CustomVariable::create($key, $value); + } + + // Hint: isset($this->$key) wouldn't conflict with protected properties + if ($this->__isset($key)) { + if ($value->equals($this->get($key))) { + return $this; + } else { + if (get_class($this->vars[$key]) === get_class($value)) { + $this->vars[$key]->setValue($value->getValue())->setModified(); + } else { + $this->vars[$key] = $value->setLoadedFromDb()->setModified(); + } + } + } else { + $this->vars[$key] = $value->setModified(); + } + + $this->modified = true; + $this->refreshIndex(); + + return $this; + } + + protected function refreshIndex() + { + $this->idx = array(); + ksort($this->vars); + foreach ($this->vars as $name => $var) { + if (! $var->hasBeenDeleted()) { + $this->idx[] = $name; + } + } + } + + public static function loadForStoredObject(IcingaObject $object) + { + $db = $object->getDb(); + + $query = $db->select()->from( + array('v' => $object->getVarsTableName()), + array( + 'v.varname', + 'v.varvalue', + 'v.format', + ) + )->where(sprintf('v.%s = ?', $object->getVarsIdColumn()), $object->get('id')); + + $vars = new CustomVariables; + foreach ($db->fetchAll($query) as $row) { + $vars->vars[$row->varname] = CustomVariable::fromDbRow($row); + } + $vars->refreshIndex(); + $vars->setBeingLoadedFromDb(); + return $vars; + } + + public static function forStoredRows($rows) + { + $vars = new CustomVariables; + foreach ($rows as $row) { + $vars->vars[$row->varname] = CustomVariable::fromDbRow($row); + } + $vars->refreshIndex(); + $vars->setBeingLoadedFromDb(); + + return $vars; + } + + public function storeToDb(IcingaObject $object) + { + $db = $object->getDb(); + $table = $object->getVarsTableName(); + $foreignColumn = $object->getVarsIdColumn(); + $foreignId = $object->get('id'); + + + foreach ($this->vars as $var) { + if ($var->isNew()) { + $db->insert( + $table, + array( + $foreignColumn => $foreignId, + 'varname' => $var->getKey(), + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ) + ); + $var->setLoadedFromDb(); + continue; + } + + $where = $db->quoteInto(sprintf('%s = ?', $foreignColumn), (int) $foreignId) + . $db->quoteInto(' AND varname = ?', $var->getKey()); + + if ($var->hasBeenDeleted()) { + $db->delete($table, $where); + } elseif ($var->hasBeenModified()) { + $db->update( + $table, + array( + 'varvalue' => $var->getDbValue(), + 'format' => $var->getDbFormat() + ), + $where + ); + } + } + + $this->setBeingLoadedFromDb(); + } + + public function get($key) + { + if (array_key_exists($key, $this->vars)) { + return $this->vars[$key]; + } + + return null; + } + + public function hasBeenModified() + { + if ($this->modified) { + return true; + } + + foreach ($this->vars as $var) { + if ($var->hasBeenModified()) { + return true; + } + } + + return false; + } + + public function setBeingLoadedFromDb() + { + $this->modified = false; + $this->storedVars = array(); + foreach ($this->vars as $key => $var) { + $this->storedVars[$key] = clone($var); + $var->setUnmodified(); + $var->setLoadedFromDb(); + } + + return $this; + } + + public function restoreStoredVar($key) + { + if (array_key_exists($key, $this->storedVars)) { + $this->vars[$key] = clone($this->storedVars[$key]); + $this->vars[$key]->setUnmodified(); + $this->recheckForModifications(); + $this->refreshIndex(); + } elseif (array_key_exists($key, $this->vars)) { + unset($this->vars[$key]); + $this->recheckForModifications(); + $this->refreshIndex(); + } + } + + protected function recheckForModifications() + { + $this->modified = false; + foreach ($this->vars as $var) { + if ($var->hasBeenModified()) { + $this->modified = true; + + return; + } + } + } + + public function getOriginalVars() + { + return $this->storedVars; + } + + public function flatten() + { + $flat = array(); + foreach ($this->vars as $key => $var) { + $var->flatten($flat, $key); + } + + return $flat; + } + + public function checksum() + { + $sums = array(); + foreach ($this->vars as $key => $var) { + $sums[] = $key . '=' . $var->checksum(); + } + + return sha1(implode('|', $sums), true); + } + + public function setOverrideKeyName($name) + { + $this->overrideKeyName = $name; + return $this; + } + + public function toConfigString($renderExpressions = false) + { + $out = ''; + + foreach ($this as $key => $var) { + // TODO: ctype_alnum + underscore? + $out .= $this->renderSingleVar($key, $var, $renderExpressions); + } + + return $out; + } + + public function toLegacyConfigString() + { + $out = ''; + + ksort($this->vars); + foreach ($this->vars as $key => $var) { + // TODO: ctype_alnum + underscore? + // vars with ARGn will be handled by IcingaObject::renderLegacyCheck_command + if (substr($key, 0, 3) == 'ARG') { + continue; + } + + switch ($type = $var->getType()) { + case 'String': + case 'Number': + # TODO: Make Prefetchable + $out .= c1::renderKeyValue( + '_' . $key, + $var->toLegacyConfigString() + ); + break; + default: + $out .= c1::renderKeyValue( + '# _' . $key, + sprintf('(unsupported: %s)', $type) + ); + } + } + + if ($out !== '') { + $out = "\n".$out; + } + + return $out; + } + + /** + * @param string $key + * @param CustomVariable $var + * @param bool $renderExpressions + * + * @return string + */ + protected function renderSingleVar($key, $var, $renderExpressions = false) + { + if ($key === $this->overrideKeyName) { + return c::renderKeyOperatorValue( + $this->renderKeyName($key), + '+=', + $var->toConfigStringPrefetchable($renderExpressions) + ); + } else { + return c::renderKeyValue( + $this->renderKeyName($key), + $var->toConfigStringPrefetchable($renderExpressions) + ); + } + } + + protected function renderKeyName($key) + { + if (preg_match('/^[a-z][a-z0-9_]*$/i', $key)) { + return 'vars.' . c::escapeIfReserved($key); + } else { + return 'vars[' . c::renderString($key) . ']'; + } + } + + public function __get($key) + { + return $this->get($key); + } + + /** + * Magic setter + * + * @param string $key Key + * @param mixed $val Value + * + * @return void + */ + public function __set($key, $val) + { + $this->set($key, $val); + } + + /** + * Magic isset check + * + * @param string $key + * + * @return boolean + */ + public function __isset($key) + { + return array_key_exists($key, $this->vars); + } + + /** + * Magic unsetter + * + * @param string $key + * + * @return void + */ + public function __unset($key) + { + if (! array_key_exists($key, $this->vars)) { + return; + } + + $this->vars[$key]->delete(); + $this->modified = true; + + $this->refreshIndex(); + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + call_user_func($previousHandler, $e); + die(); + } + } +} diff --git a/library/Director/Daemon/BackgroundDaemon.php b/library/Director/Daemon/BackgroundDaemon.php new file mode 100644 index 0000000..34cc28b --- /dev/null +++ b/library/Director/Daemon/BackgroundDaemon.php @@ -0,0 +1,235 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Exception; +use gipfl\Cli\Process; +use gipfl\IcingaCliDaemon\DbResourceConfigWatch; +use gipfl\SystemD\NotifySystemD; +use React\EventLoop\Factory as Loop; +use React\EventLoop\LoopInterface; +use Ramsey\Uuid\Uuid; + +class BackgroundDaemon +{ + /** @var LoopInterface */ + private $loop; + + /** @var NotifySystemD|boolean */ + protected $systemd; + + /** @var JobRunner */ + protected $jobRunner; + + /** @var string|null */ + protected $dbResourceName; + + /** @var DaemonDb */ + protected $daemonDb; + + /** @var DaemonProcessState */ + protected $processState; + + /** @var DaemonProcessDetails */ + protected $processDetails; + + /** @var LogProxy */ + protected $logProxy; + + /** @var bool */ + protected $reloading = false; + + /** @var bool */ + protected $shuttingDown = false; + + public function run(LoopInterface $loop = null) + { + if ($ownLoop = ($loop === null)) { + $loop = Loop::create(); + } + $this->loop = $loop; + $this->loop->futureTick(function () { + $this->initialize(); + }); + if ($ownLoop) { + $loop->run(); + } + } + + public function setDbResourceName($name) + { + $this->dbResourceName = $name; + + return $this; + } + + protected function initialize() + { + $this->registerSignalHandlers($this->loop); + $this->processState = new DaemonProcessState('icinga::director'); + $this->jobRunner = new JobRunner($this->loop); + $this->systemd = $this->eventuallyInitializeSystemd(); + $this->processState->setSystemd($this->systemd); + if ($this->systemd) { + $this->systemd->setReady(); + } + $this->setState('ready'); + $this->processDetails = $this + ->initializeProcessDetails($this->systemd) + ->registerProcessList($this->jobRunner->getProcessList()); + $this->logProxy = new LogProxy($this->processDetails->getInstanceUuid()); + $this->jobRunner->forwardLog($this->logProxy); + $this->daemonDb = $this->initializeDb( + $this->processDetails, + $this->processState, + $this->dbResourceName + ); + $this->daemonDb + ->register($this->jobRunner) + ->register($this->logProxy) + ->register(new DeploymentChecker($this->loop)) + ->run($this->loop); + $this->setState('running'); + } + + /** + * @param NotifySystemD|false $systemd + * @return DaemonProcessDetails + */ + protected function initializeProcessDetails($systemd) + { + if ($systemd && $systemd->hasInvocationId()) { + $uuid = $systemd->getInvocationId(); + } else { + try { + $uuid = \bin2hex(Uuid::uuid4()->getBytes()); + } catch (Exception $e) { + $uuid = 'deadc0de' . \substr(\md5(\getmypid()), 0, 24); + } + } + $processDetails = new DaemonProcessDetails($uuid); + if ($systemd) { + $processDetails->set('running_with_systemd', 'y'); + } + + return $processDetails; + } + + protected function eventuallyInitializeSystemd() + { + $systemd = NotifySystemD::ifRequired($this->loop); + if ($systemd) { + Logger::replaceRunningInstance(new SystemdLogWriter()); + Logger::info(sprintf( + "Started by systemd, notifying watchdog every %0.2Gs via %s", + $systemd->getWatchdogInterval(), + $systemd->getSocketPath() + )); + } else { + Logger::debug('Running without systemd'); + } + + return $systemd; + } + + /** + * @return DaemonProcessDetails + */ + public function getProcessDetails() + { + return $this->processDetails; + } + + /** + * @return DaemonProcessState + */ + public function getProcessState() + { + return $this->processState; + } + + protected function initializeDb( + DaemonProcessDetails $processDetails, + DaemonProcessState $processState, + $dbResourceName = null + ) { + $db = new DaemonDb($processDetails); + $db->on('state', function ($state, $level = null) use ($processState) { + // TODO: level is sent but not used + $processState->setComponentState('db', $state); + }); + $db->on('schemaChange', function ($startupSchema, $dbSchema) { + Logger::info(sprintf( + "DB schema version changed. Started with %d, DB has %d. Restarting.", + $startupSchema, + $dbSchema + )); + $this->reload(); + }); + + $db->setConfigWatch( + $dbResourceName + ? DbResourceConfigWatch::name($dbResourceName) + : DbResourceConfigWatch::module('director') + ); + + return $db; + } + + protected function registerSignalHandlers(LoopInterface $loop) + { + $func = function ($signal) use (&$func) { + $this->shutdownWithSignal($signal, $func); + }; + $funcReload = function () { + $this->reload(); + }; + $loop->addSignal(SIGHUP, $funcReload); + $loop->addSignal(SIGINT, $func); + $loop->addSignal(SIGTERM, $func); + } + + protected function shutdownWithSignal($signal, &$func) + { + $this->loop->removeSignal($signal, $func); + $this->shutdown(); + } + + public function reload() + { + if ($this->reloading) { + Logger::error('Ignoring reload request, reload is already in progress'); + return; + } + $this->reloading = true; + Logger::info('Going gown for reload now'); + $this->setState('reloading the main process'); + $this->daemonDb->disconnect()->then(function () { + Process::restart(); + }); + } + + protected function shutdown() + { + if ($this->shuttingDown) { + Logger::error('Ignoring shutdown request, shutdown is already in progress'); + return; + } + Logger::info('Shutting down'); + $this->shuttingDown = true; + $this->setState('shutting down'); + $this->daemonDb->disconnect()->then(function () { + Logger::info('DB has been disconnected, shutdown finished'); + $this->loop->stop(); + }); + } + + protected function setState($state) + { + if ($this->processState) { + $this->processState->setState($state); + } + + return $this; + } +} diff --git a/library/Director/Daemon/DaemonDb.php b/library/Director/Daemon/DaemonDb.php new file mode 100644 index 0000000..7772b3a --- /dev/null +++ b/library/Director/Daemon/DaemonDb.php @@ -0,0 +1,365 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Exception; +use gipfl\IcingaCliDaemon\DbResourceConfigWatch; +use gipfl\IcingaCliDaemon\RetryUnless; +use Icinga\Data\ConfigObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Migrations; +use ipl\Stdlib\EventEmitter; +use React\EventLoop\LoopInterface; +use React\Promise\Deferred; +use RuntimeException; +use SplObjectStorage; +use function React\Promise\reject; +use function React\Promise\resolve; + +class DaemonDb +{ + use EventEmitter; + + /** @var LoopInterface */ + private $loop; + + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var DaemonProcessDetails */ + protected $details; + + /** @var DbBasedComponent[] */ + protected $registeredComponents = []; + + /** @var DbResourceConfigWatch|null */ + protected $configWatch; + + /** @var array|null */ + protected $dbConfig; + + /** @var RetryUnless|null */ + protected $pendingReconnection; + + /** @var Deferred|null */ + protected $pendingDisconnect; + + /** @var \React\EventLoop\TimerInterface */ + protected $refreshTimer; + + /** @var \React\EventLoop\TimerInterface */ + protected $schemaCheckTimer; + + /** @var int */ + protected $startupSchemaVersion; + + public function __construct(DaemonProcessDetails $details, $dbConfig = null) + { + $this->details = $details; + $this->dbConfig = $dbConfig; + } + + public function register(DbBasedComponent $component) + { + $this->registeredComponents[] = $component; + + return $this; + } + + public function setConfigWatch(DbResourceConfigWatch $configWatch) + { + $this->configWatch = $configWatch; + $configWatch->notify(function ($config) { + $this->disconnect()->then(function () use ($config) { + return $this->onNewConfig($config); + }); + }); + if ($this->loop) { + $configWatch->run($this->loop); + } + + return $this; + } + + public function run(LoopInterface $loop) + { + $this->loop = $loop; + $this->connect(); + $this->refreshTimer = $loop->addPeriodicTimer(3, function () { + $this->refreshMyState(); + }); + $this->schemaCheckTimer = $loop->addPeriodicTimer(15, function () { + $this->checkDbSchema(); + }); + if ($this->configWatch) { + $this->configWatch->run($this->loop); + } + } + + protected function onNewConfig($config) + { + if ($config === null) { + if ($this->dbConfig === null) { + Logger::error('DB configuration is not valid'); + } else { + Logger::error('DB configuration is no longer valid'); + } + $this->emitStatus('no configuration'); + $this->dbConfig = $config; + + return resolve(); + } else { + $this->emitStatus('configuration loaded'); + $this->dbConfig = $config; + + return $this->establishConnection($config); + } + } + + protected function establishConnection($config) + { + if ($this->connection !== null) { + Logger::error('Trying to establish a connection while being connected'); + return reject(); + } + $callback = function () use ($config) { + $this->reallyEstablishConnection($config); + }; + $onSuccess = function () { + $this->pendingReconnection = null; + $this->onConnected(); + }; + if ($this->pendingReconnection) { + $this->pendingReconnection->reset(); + $this->pendingReconnection = null; + } + $this->emitStatus('connecting'); + + return $this->pendingReconnection = RetryUnless::succeeding($callback) + ->setInterval(0.2) + ->slowDownAfter(10, 10) + ->run($this->loop) + ->then($onSuccess) + ; + } + + protected function reallyEstablishConnection($config) + { + $connection = new Db(new ConfigObject($config)); + $connection->getDbAdapter()->getConnection(); + $migrations = new Migrations($connection); + if (! $migrations->hasSchema()) { + $this->emitStatus('no schema', 'error'); + throw new RuntimeException('DB has no schema'); + } + $this->wipeOrphanedInstances($connection); + if ($this->hasAnyOtherActiveInstance($connection)) { + $this->emitStatus('locked by other instance', 'warning'); + throw new RuntimeException('DB is locked by a running daemon instance, will retry'); + } + $this->startupSchemaVersion = $migrations->getLastMigrationNumber(); + $this->details->set('schema_version', $this->startupSchemaVersion); + + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + $this->loop->futureTick(function () { + $this->refreshMyState(); + }); + + return $connection; + } + + protected function checkDbSchema() + { + if ($this->connection === null) { + return; + } + + if ($this->schemaIsOutdated()) { + $this->emit('schemaChange', [ + $this->getStartupSchemaVersion(), + $this->getDbSchemaVersion() + ]); + } + } + + protected function schemaIsOutdated() + { + return $this->getStartupSchemaVersion() < $this->getDbSchemaVersion(); + } + + protected function getStartupSchemaVersion() + { + return $this->startupSchemaVersion; + } + + protected function getDbSchemaVersion() + { + if ($this->connection === null) { + throw new RuntimeException( + 'Cannot determine DB schema version without an established DB connection' + ); + } + $migrations = new Migrations($this->connection); + + return $migrations->getLastMigrationNumber(); + } + + protected function onConnected() + { + $this->emitStatus('connected'); + Logger::info('Connected to the database'); + foreach ($this->registeredComponents as $component) { + $component->initDb($this->connection); + } + } + + /** + * @return \React\Promise\PromiseInterface + */ + protected function reconnect() + { + return $this->disconnect()->then(function () { + return $this->connect(); + }, function (Exception $e) { + Logger::error('Disconnect failed. This should never happen: ' . $e->getMessage()); + exit(1); + }); + } + + /** + * @return \React\Promise\ExtendedPromiseInterface + */ + public function connect() + { + if ($this->connection === null) { + if ($this->dbConfig) { + return $this->establishConnection($this->dbConfig); + } + } + + return resolve(); + } + + /** + * @return \React\Promise\ExtendedPromiseInterface + */ + public function disconnect() + { + if (! $this->connection) { + return resolve(); + } + if ($this->pendingDisconnect) { + return $this->pendingDisconnect->promise(); + } + + $this->eventuallySetStopped(); + $this->pendingDisconnect = new Deferred(); + $pendingComponents = new SplObjectStorage(); + foreach ($this->registeredComponents as $component) { + $pendingComponents->attach($component); + $resolve = function () use ($pendingComponents, $component) { + $pendingComponents->detach($component); + if ($pendingComponents->count() === 0) { + $this->pendingDisconnect->resolve(); + } + }; + // TODO: What should we do in case they don't? + $component->stopDb()->then($resolve); + } + + try { + if ($this->db) { + $this->db->closeConnection(); + } + } catch (Exception $e) { + Logger::error('Failed to disconnect: ' . $e->getMessage()); + } + + return $this->pendingDisconnect->promise()->then(function () { + $this->connection = null; + $this->db = null; + $this->pendingDisconnect = null; + }); + } + + protected function emitStatus($message, $level = 'info') + { + $this->emit('state', [$message, $level]); + + return $this; + } + + protected function hasAnyOtherActiveInstance(Db $connection) + { + $db = $connection->getDbAdapter(); + + return (int) $db->fetchOne( + $db->select() + ->from('director_daemon_info', 'COUNT(*)') + ->where('ts_stopped IS NULL') + ) > 0; + } + + protected function wipeOrphanedInstances(Db $connection) + { + $db = $connection->getDbAdapter(); + $db->delete('director_daemon_info', 'ts_stopped IS NOT NULL'); + $db->delete('director_daemon_info', $db->quoteInto( + 'instance_uuid_hex = ?', + $this->details->getInstanceUuid() + )); + $count = $db->delete( + 'director_daemon_info', + 'ts_stopped IS NULL AND ts_last_update < ' . ( + DaemonUtil::timestampWithMilliseconds() - (60 * 1000) + ) + ); + if ($count > 1) { + Logger::error("Removed $count orphaned daemon instance(s) from DB"); + } + } + + protected function refreshMyState() + { + if ($this->db === null || $this->pendingReconnection || $this->pendingDisconnect) { + return; + } + try { + $updated = $this->db->update( + 'director_daemon_info', + $this->details->getPropertiesToUpdate(), + $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid()) + ); + + if (! $updated) { + $this->db->insert( + 'director_daemon_info', + $this->details->getPropertiesToInsert() + ); + } + } catch (Exception $e) { + Logger::error($e->getMessage()); + $this->reconnect(); + } + } + + protected function eventuallySetStopped() + { + try { + if (! $this->db) { + return; + } + $this->db->update( + 'director_daemon_info', + ['ts_stopped' => DaemonUtil::timestampWithMilliseconds()], + $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid()) + ); + } catch (Exception $e) { + Logger::error('Failed to update daemon info (setting ts_stopped): ' . $e->getMessage()); + } + } +} diff --git a/library/Director/Daemon/DaemonProcessDetails.php b/library/Director/Daemon/DaemonProcessDetails.php new file mode 100644 index 0000000..454e31f --- /dev/null +++ b/library/Director/Daemon/DaemonProcessDetails.php @@ -0,0 +1,122 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use gipfl\LinuxHealth\Memory; +use Icinga\Application\Platform; +use React\ChildProcess\Process; +use gipfl\Cli\Process as CliProcess; + +class DaemonProcessDetails +{ + /** @var string */ + protected $instanceUuid; + + /** @var \stdClass */ + protected $info; + + /** @var ProcessList[] */ + protected $processLists = []; + + protected $myArgs; + + protected $myPid; + + public function __construct($instanceUuid) + { + $this->instanceUuid = $instanceUuid; + $this->initialize(); + } + + public function getInstanceUuid() + { + return $this->instanceUuid; + } + + public function getPropertiesToInsert() + { + return $this->getPropertiesToUpdate() + (array) $this->info; + } + + public function getPropertiesToUpdate() + { + return [ + 'ts_last_update' => DaemonUtil::timestampWithMilliseconds(), + 'ts_stopped' => null, + 'process_info' => \json_encode($this->collectProcessInfo()), + ]; + } + + public function set($property, $value) + { + if (\property_exists($this->info, $property)) { + $this->info->$property = $value; + } else { + throw new \InvalidArgumentException("Trying to set invalid daemon info property: $property"); + } + } + + public function registerProcessList(ProcessList $list) + { + $refresh = function (Process $process) { + $this->refreshProcessInfo(); + }; + $list->on('start', $refresh)->on('exit', $refresh); + $this->processLists[] = $list; + + return $this; + } + + protected function refreshProcessInfo() + { + $this->set('process_info', \json_encode($this->collectProcessInfo())); + } + + protected function collectProcessInfo() + { + $info = (object) [$this->myPid => (object) [ + 'command' => implode(' ', $this->myArgs), + 'running' => true, + 'memory' => Memory::getUsageForPid($this->myPid) + ]]; + + foreach ($this->processLists as $processList) { + foreach ($processList->getOverview() as $pid => $details) { + $info->$pid = $details; + } + } + + return $info; + } + + protected function initialize() + { + global $argv; + CliProcess::getInitialCwd(); + $this->myArgs = $argv; + $this->myPid = \posix_getpid(); + if (isset($_SERVER['_'])) { + $self = $_SERVER['_']; + } else { + // Process does a better job, but want the relative path (if such) + $self = $_SERVER['PHP_SELF']; + } + $this->info = (object) [ + 'instance_uuid_hex' => $this->instanceUuid, + 'running_with_systemd' => 'n', + 'ts_started' => (int) ((float) $_SERVER['REQUEST_TIME_FLOAT'] * 1000), + 'ts_stopped' => null, + 'pid' => \posix_getpid(), + 'fqdn' => Platform::getFqdn(), + 'username' => Platform::getPhpUser(), + 'schema_version' => null, + 'php_version' => Platform::getPhpVersion(), + 'binary_path' => $self, + 'binary_realpath' => CliProcess::getBinaryPath(), + 'php_integer_size' => PHP_INT_SIZE, + 'php_binary_path' => PHP_BINARY, + 'php_binary_realpath' => \realpath(PHP_BINARY), // TODO: useless? + 'process_info' => null, + ]; + } +} diff --git a/library/Director/Daemon/DaemonProcessState.php b/library/Director/Daemon/DaemonProcessState.php new file mode 100644 index 0000000..6ae3cd2 --- /dev/null +++ b/library/Director/Daemon/DaemonProcessState.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use gipfl\Cli\Process; +use gipfl\SystemD\NotifySystemD; + +class DaemonProcessState +{ + /** @var NotifySystemD|null */ + protected $systemd; + + protected $components = []; + + protected $currentMessage; + + protected $processTitle; + + protected $state; + + public function __construct($processTitle) + { + $this->processTitle = $processTitle; + $this->refreshMessage(); + } + + /** + * @param NotifySystemD|false $systemd + * @return $this + */ + public function setSystemd($systemd) + { + if ($systemd) { + $this->systemd = $systemd; + } else { + $this->systemd = null; + } + + return $this; + } + + public function setState($message) + { + $this->state = $message; + $this->refreshMessage(); + + return $this; + } + + public function setComponentState($name, $stateMessage) + { + if ($stateMessage === null) { + unset($this->components[$name]); + } else { + $this->components[$name] = $stateMessage; + } + $this->refreshMessage(); + } + + protected function refreshMessage() + { + $messageParts = []; + if ($this->state !== null && \strlen($this->state)) { + $messageParts[] = $this->state; + } + foreach ($this->components as $component => $message) { + $messageParts[] = "$component: $message"; + } + + $message = \implode(', ', $messageParts); + + if ($message !== $this->currentMessage) { + $this->currentMessage = $message; + if (\strlen($message) === 0) { + Process::setTitle($this->processTitle); + } else { + Process::setTitle($this->processTitle . ": $message"); + } + + if ($this->systemd) { + $this->systemd->setStatus($message); + } + } + } +} diff --git a/library/Director/Daemon/DaemonUtil.php b/library/Director/Daemon/DaemonUtil.php new file mode 100644 index 0000000..c978d11 --- /dev/null +++ b/library/Director/Daemon/DaemonUtil.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +class DaemonUtil +{ + /** + * @return int + */ + public static function timestampWithMilliseconds() + { + $mTime = explode(' ', microtime()); + + return (int) round($mTime[0] * 1000) + (int) $mTime[1] * 1000; + } +} diff --git a/library/Director/Daemon/DbBasedComponent.php b/library/Director/Daemon/DbBasedComponent.php new file mode 100644 index 0000000..c176c14 --- /dev/null +++ b/library/Director/Daemon/DbBasedComponent.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Icinga\Module\Director\Db; + +interface DbBasedComponent +{ + /** + * @param Db $db + * @return \React\Promise\ExtendedPromiseInterface; + */ + public function initDb(Db $db); + + /** + * @return \React\Promise\ExtendedPromiseInterface; + */ + public function stopDb(); +} diff --git a/library/Director/Daemon/DeploymentChecker.php b/library/Director/Daemon/DeploymentChecker.php new file mode 100644 index 0000000..82d6d05 --- /dev/null +++ b/library/Director/Daemon/DeploymentChecker.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Exception; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use React\EventLoop\LoopInterface; +use function React\Promise\resolve; + +class DeploymentChecker implements DbBasedComponent +{ + /** @var Db */ + protected $connection; + + public function __construct(LoopInterface $loop) + { + $loop->addPeriodicTimer(5, function () { + if ($db = $this->connection) { + try { + if (DirectorDeploymentLog::hasUncollected($db)) { + $db->getDeploymentEndpoint()->api()->collectLogFiles($db); + } + } catch (Exception $e) { + // Ignore eventual issues while talking to Icinga + } + } + }); + } + + /** + * @param Db $connection + * @return \React\Promise\ExtendedPromiseInterface + */ + public function initDb(Db $connection) + { + $this->connection = $connection; + + return resolve(); + } + + /** + * @return \React\Promise\ExtendedPromiseInterface + */ + public function stopDb() + { + $this->connection = null; + + return resolve(); + } +} diff --git a/library/Director/Daemon/JobRunner.php b/library/Director/Daemon/JobRunner.php new file mode 100644 index 0000000..78d7747 --- /dev/null +++ b/library/Director/Daemon/JobRunner.php @@ -0,0 +1,234 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use gipfl\IcingaCliDaemon\FinishedProcessState; +use gipfl\IcingaCliDaemon\IcingaCliRpc; +use Icinga\Application\Logger; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorJob; +use React\ChildProcess\Process; +use React\EventLoop\LoopInterface; +use React\Promise\Promise; +use function React\Promise\resolve; + +class JobRunner implements DbBasedComponent +{ + /** @var Db */ + protected $db; + + /** @var LoopInterface */ + protected $loop; + + /** @var int[] */ + protected $scheduledIds = []; + + /** @var Promise[] */ + protected $runningIds = []; + + protected $checkInterval = 10; + + /** @var \React\EventLoop\TimerInterface */ + protected $timer; + + /** @var LogProxy */ + protected $logProxy; + + /** @var ProcessList */ + protected $running; + + public function __construct(LoopInterface $loop) + { + $this->loop = $loop; + $this->running = new ProcessList($loop); + } + + public function forwardLog(LogProxy $logProxy) + { + $this->logProxy = $logProxy; + + return $this; + } + + /** + * @param Db $db + * @return \React\Promise\ExtendedPromiseInterface + */ + public function initDb(Db $db) + { + $this->db = $db; + $check = function () { + try { + $this->checkForPendingJobs(); + $this->runNextPendingJob(); + } catch (\Exception $e) { + Logger::error($e->getMessage()); + } + }; + if ($this->timer === null) { + $this->loop->futureTick($check); + } + if ($this->timer !== null) { + Logger::info('Cancelling former timer'); + $this->loop->cancelTimer($this->timer); + } + $this->timer = $this->loop->addPeriodicTimer($this->checkInterval, $check); + + return resolve(); + } + + /** + * @return \React\Promise\ExtendedPromiseInterface + */ + public function stopDb() + { + $this->scheduledIds = []; + if ($this->timer !== null) { + $this->loop->cancelTimer($this->timer); + $this->timer = null; + } + $allFinished = $this->running->killOrTerminate(); + foreach ($this->runningIds as $id => $promise) { + $promise->cancel(); + } + $this->runningIds = []; + + return $allFinished; + } + + protected function hasBeenDisabled() + { + $db = $this->db->getDbAdapter(); + return $db->fetchOne( + $db->select() + ->from('director_setting', 'setting_value') + ->where('setting_name = ?', 'disable_all_jobs') + ) === 'y'; + } + + protected function checkForPendingJobs() + { + if ($this->hasBeenDisabled()) { + $this->scheduledIds = []; + // TODO: disable jobs currently going on? + return; + } + if (empty($this->scheduledIds)) { + $this->loadNextIds(); + } + } + + protected function runNextPendingJob() + { + if ($this->timer === null) { + // Reset happened. Stopping? + return; + } + + if (! empty($this->runningIds)) { + return; + } + while (! empty($this->scheduledIds)) { + if ($this->runNextJob()) { + break; + } + } + } + + protected function loadNextIds() + { + $db = $this->db->getDbAdapter(); + + foreach ($db->fetchCol( + $db->select()->from('director_job', 'id')->where('disabled = ?', 'n') + ) as $id) { + $this->scheduledIds[] = (int) $id; + }; + } + + /** + * @return bool + */ + protected function runNextJob() + { + $id = \array_shift($this->scheduledIds); + try { + $job = DirectorJob::loadWithAutoIncId((int) $id, $this->db); + if ($job->shouldRun()) { + $this->runJob($job); + return true; + } + } catch (\Exception $e) { + Logger::error('Trying to schedule Job failed: ' . $e->getMessage()); + } + + return false; + } + + /** + * @param DirectorJob $job + */ + protected function runJob(DirectorJob $job) + { + $id = $job->get('id'); + $jobName = $job->get('job_name'); + Logger::debug("Job ($jobName) starting"); + $arguments = [ + 'director', + 'job', + 'run', + '--id', + $job->get('id'), + '--debug', + '--rpc' + ]; + $cli = new IcingaCliRpc(); + $cli->setArguments($arguments); + $cli->on('start', function (Process $process) { + $this->onProcessStarted($process); + }); + + // Happens on protocol (Netstring) errors or similar: + $cli->on('error', function (\Exception $e) { + Logger::error('UNEXPECTED: ' . rtrim($e->getMessage())); + }); + if ($this->logProxy) { + $logger = clone($this->logProxy); + $logger->setPrefix("Job ($jobName): "); + $cli->rpc()->setHandler($logger, 'logger'); + } + unset($this->scheduledIds[$id]); + $this->runningIds[$id] = $cli->run($this->loop)->then(function () use ($id, $jobName) { + Logger::debug("Job ($jobName) finished"); + })->otherwise(function (\Exception $e) use ($id, $jobName) { + Logger::error("Job ($jobName) failed: " . $e->getMessage()); + })->otherwise(function (FinishedProcessState $state) use ($jobName) { + Logger::error("Job ($jobName) failed: " . $state->getReason()); + })->always(function () use ($id) { + unset($this->runningIds[$id]); + $this->loop->futureTick(function () { + $this->runNextPendingJob(); + }); + }); + } + + /** + * @return ProcessList + */ + public function getProcessList() + { + return $this->running; + } + + protected function onProcessStarted(Process $process) + { + $this->running->attach($process); + } + + public function __destruct() + { + $this->stopDb(); + $this->logProxy = null; + $this->loop = null; + } +} diff --git a/library/Director/Daemon/JsonRpcLogWriter.php b/library/Director/Daemon/JsonRpcLogWriter.php new file mode 100644 index 0000000..edfa23e --- /dev/null +++ b/library/Director/Daemon/JsonRpcLogWriter.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use gipfl\Protocol\JsonRpc\Connection; +use gipfl\Protocol\JsonRpc\Notification; +use Icinga\Application\Logger\LogWriter; +use Icinga\Data\ConfigObject; + +class JsonRpcLogWriter extends LogWriter +{ + protected $connection; + + protected static $severityMap = [ + Logger::DEBUG => 'debug', + Logger::INFO => 'info', + Logger::WARNING => 'warning', + Logger::ERROR => 'error', + ]; + + public function __construct(Connection $connection) + { + parent::__construct(new ConfigObject([])); + $this->connection = $connection; + } + + public function log($severity, $message) + { + $message = \iconv('UTF-8', 'UTF-8//IGNORE', $message); + $this->connection->sendNotification( + Notification::create('logger.log', [ + static::$severityMap[$severity], + $message + ]) + ); + } +} diff --git a/library/Director/Daemon/LogProxy.php b/library/Director/Daemon/LogProxy.php new file mode 100644 index 0000000..0b58ae8 --- /dev/null +++ b/library/Director/Daemon/LogProxy.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Exception; +use Icinga\Module\Director\Db; +use function React\Promise\resolve; + +class LogProxy implements DbBasedComponent +{ + protected $connection; + + protected $db; + + protected $server; + + protected $instanceUuid; + + protected $prefix = ''; + + public function __construct($instanceUuid) + { + $this->instanceUuid = $instanceUuid; + } + + public function setPrefix($prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * @param Db $connection + * @return \React\Promise\ExtendedPromiseInterface + */ + public function initDb(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + + return resolve(); + } + + /** + * @return \React\Promise\ExtendedPromiseInterface + */ + public function stopDb() + { + $this->connection = null; + $this->db = null; + + return resolve(); + } + + public function log($severity, $message) + { + Logger::$severity($this->prefix . $message); + /* + // Not yet + try { + if ($this->db) { + $this->db->insert('director_daemonlog', [ + // environment/installation/db? + 'instance_uuid' => $this->instanceUuid, + 'ts_create' => DaemonUtil::timestampWithMilliseconds(), + 'level' => $severity, + 'message' => $message, + ]); + } + } catch (Exception $e) { + Logger::error($e->getMessage()); + } + */ + } +} diff --git a/library/Director/Daemon/Logger.php b/library/Director/Daemon/Logger.php new file mode 100644 index 0000000..27fcbf5 --- /dev/null +++ b/library/Director/Daemon/Logger.php @@ -0,0 +1,24 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Icinga\Application\Logger as IcingaLogger; +use Icinga\Application\Logger\LogWriter; +use Icinga\Exception\ConfigurationError; + +class Logger extends IcingaLogger +{ + public static function replaceRunningInstance(LogWriter $writer, $level = null) + { + try { + $instance = static::$instance; + if ($level !== null) { + $instance->setLevel($level); + } + + $instance->writer = $writer; + } catch (ConfigurationError $e) { + self::$instance->error($e->getMessage()); + } + } +} diff --git a/library/Director/Daemon/ProcessList.php b/library/Director/Daemon/ProcessList.php new file mode 100644 index 0000000..85b9aac --- /dev/null +++ b/library/Director/Daemon/ProcessList.php @@ -0,0 +1,125 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use gipfl\LinuxHealth\Memory; +use Icinga\Application\Logger; +use ipl\Stdlib\EventEmitter; +use React\ChildProcess\Process; +use React\EventLoop\LoopInterface; +use React\Promise\Deferred; +use function React\Promise\resolve; + +class ProcessList +{ + use EventEmitter; + + /** @var LoopInterface */ + protected $loop; + + /** @var \SplObjectStorage */ + protected $processes; + + /** + * ProcessList constructor. + * @param LoopInterface $loop + * @param Process[] $processes + */ + public function __construct(LoopInterface $loop, array $processes = []) + { + $this->loop = $loop; + $this->processes = new \SplObjectStorage(); + foreach ($processes as $process) { + $this->attach($process); + } + } + + public function attach(Process $process) + { + $this->processes->attach($process); + $this->emit('start', [$process]); + $process->on('exit', function () use ($process) { + $this->detach($process); + $this->emit('exit', [$process]); + }); + + return $this; + } + + public function detach(Process $process) + { + $this->processes->detach($process); + + return $this; + } + + /** + * @param int $timeout + * @return \React\Promise\ExtendedPromiseInterface + */ + public function killOrTerminate($timeout = 5) + { + if ($this->processes->count() === 0) { + return resolve(); + } + $deferred = new Deferred(); + $killTimer = $this->loop->addTimer($timeout, function () use ($deferred) { + /** @var Process $process */ + foreach ($this->processes as $process) { + $pid = $process->getPid(); + Logger::error("Process $pid is still running, sending SIGKILL"); + $process->terminate(SIGKILL); + } + + // Let's a little bit of delay after KILLing + $this->loop->addTimer(0.1, function () use ($deferred) { + $deferred->resolve(); + }); + }); + + $timer = $this->loop->addPeriodicTimer($timeout / 20, function () use ( + $deferred, + &$timer, + $killTimer + ) { + $stopped = []; + /** @var Process $process */ + foreach ($this->processes as $process) { + if (! $process->isRunning()) { + $stopped[] = $process; + } + } + foreach ($stopped as $process) { + $this->processes->detach($process); + } + if ($this->processes->count() === 0) { + $this->loop->cancelTimer($timer); + $this->loop->cancelTimer($killTimer); + $deferred->resolve(); + } + }); + /** @var Process $process */ + foreach ($this->processes as $process) { + $process->terminate(SIGTERM); + } + + return $deferred->promise(); + } + + public function getOverview() + { + $info = []; + + /** @var Process $process */ + foreach ($this->processes as $process) { + $pid = $process->getPid(); + $info[$pid] = (object) [ + 'command' => preg_replace('/^exec /', '', $process->getCommand()), + 'running' => $process->isRunning(), + 'memory' => Memory::getUsageForPid($pid) + ]; + } + + return $info; + } +} diff --git a/library/Director/Daemon/RunningDaemonInfo.php b/library/Director/Daemon/RunningDaemonInfo.php new file mode 100644 index 0000000..adb3549 --- /dev/null +++ b/library/Director/Daemon/RunningDaemonInfo.php @@ -0,0 +1,154 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +class RunningDaemonInfo +{ + /** @var object */ + protected $info; + + public function __construct($info = null) + { + $this->setInfo($info); + } + + public function setInfo($info) + { + if (empty($info)) { + $this->info = $this->createEmptyInfo(); + } else { + $this->info = $info; + } + + return $this; + } + + public function isRunning() + { + return $this->getPid() !== null && ! $this->isOutdated(); + } + + public function getPid() + { + return (int) $this->info->pid; + } + + public function getUsername() + { + return $this->info->username; + } + + public function getFqdn() + { + return $this->info->fqdn; + } + + public function getLastUpdate() + { + return $this->info->ts_last_update; + } + + public function getLastModification() + { + return $this->info->ts_last_modification; + } + + public function getPhpVersion() + { + return $this->info->php_version; + } + + public function hasBeenStopped() + { + return $this->getTimestampStopped() !== null; + } + + public function getTimestampStarted() + { + return $this->info->ts_started; + } + + public function getTimestampStopped() + { + return $this->info->ts_stopped; + } + + public function isOutdated($seconds = 5) + { + return ( + DaemonUtil::timestampWithMilliseconds() - $this->info->ts_last_update + ) > $seconds * 1000; + } + + public function isRunningWithSystemd() + { + return $this->info->running_with_systemd === 'y'; + } + + public function getBinaryPath() + { + return $this->info->binary_path; + } + + public function getBinaryRealpath() + { + return $this->info->binary_realpath; + } + + public function binaryRealpathDiffers() + { + return $this->getBinaryPath() !== $this->getBinaryRealpath(); + } + + public function getPhpBinaryPath() + { + return $this->info->php_binary_path; + } + + public function getPhpBinaryRealpath() + { + return $this->info->php_binary_realpath; + } + + public function phpBinaryRealpathDiffers() + { + return $this->getPhpBinaryPath() !== $this->getPhpBinaryRealpath(); + } + + public function getPhpIntegerSize() + { + return (int) $this->info->php_integer_size; + } + + public function has64bitIntegers() + { + return $this->getPhpIntegerSize() === 8; + } + + /* + // TODO: not yet + public function isMaster() + { + return $this->info->is_master === 'y'; + } + + public function isStandby() + { + return ! $this->isMaster(); + } + */ + + protected function createEmptyInfo() + { + return (object) [ + 'pid' => null, + 'fqdn' => null, + 'username' => null, + 'php_version' => null, + // 'is_master' => null, + // Only if not running. Does this make any sense in 'empty info'? + 'ts_last_update' => null, + 'ts_last_modification' => null + ]; + } +} diff --git a/library/Director/Daemon/SystemdLogWriter.php b/library/Director/Daemon/SystemdLogWriter.php new file mode 100644 index 0000000..8b64442 --- /dev/null +++ b/library/Director/Daemon/SystemdLogWriter.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Daemon; + +use Icinga\Application\Logger\LogWriter; +use Icinga\Data\ConfigObject; + +class SystemdLogWriter extends LogWriter +{ + protected static $severityMap = [ + Logger::DEBUG => 7, + Logger::INFO => 6, + Logger::WARNING => 4, + Logger::ERROR => 3, + ]; + + public function __construct() + { + parent::__construct(new ConfigObject([])); + } + + public function log($severity, $message) + { + $severity = self::$severityMap[$severity]; + echo "<$severity>$message\n"; + } +} diff --git a/library/Director/Dashboard/AlertsDashboard.php b/library/Director/Dashboard/AlertsDashboard.php new file mode 100644 index 0000000..447f74f --- /dev/null +++ b/library/Director/Dashboard/AlertsDashboard.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class AlertsDashboard extends Dashboard +{ + protected $dashletNames = array( + 'Notifications', + 'Users', + 'Timeperiods', + 'DependencyObject', + 'ScheduledDowntimeApply', + ); + + public function getTitle() + { + return $this->translate('Get alerts when something goes wrong'); + } +} diff --git a/library/Director/Dashboard/AutomationDashboard.php b/library/Director/Dashboard/AutomationDashboard.php new file mode 100644 index 0000000..dd07d71 --- /dev/null +++ b/library/Director/Dashboard/AutomationDashboard.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class AutomationDashboard extends Dashboard +{ + protected $dashletNames = array( + 'ImportSource', + 'Sync', + 'Job' + ); + + public function getTitle() + { + return $this->translate('Automate all tasks'); + } +} diff --git a/library/Director/Dashboard/BranchesDashboard.php b/library/Director/Dashboard/BranchesDashboard.php new file mode 100644 index 0000000..fe8b385 --- /dev/null +++ b/library/Director/Dashboard/BranchesDashboard.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +use gipfl\Web\Widget\Hint; +use Icinga\Application\Hook; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchStore; +use Icinga\Module\Director\Hook\BranchSupportHook; +use ipl\Html\Html; + +class BranchesDashboard extends Dashboard +{ + public function getTitle() + { + $branch = Branch::detect(new BranchStore($this->getDb())); + if ($branch->isBranch()) { + $this->prepend(Hint::info(Html::sprintf( + $this->translate('You\'re currently working in a Configuration Branch: %s'), + Branch::requireHook()->linkToBranch($branch, $this->getAuth(), $branch->getName()) + ))); + } + + return $this->translate('Prepare your configuration in a safe Environment'); + } + + public function loadDashlets() + { + /** @var BranchSupportHook $hook */ + if ($hook = Hook::first('director/BranchSupport')) { + $this->dashlets = $hook->loadDashlets($this->getDb()); + } else { + $this->dashlets = []; + } + } +} diff --git a/library/Director/Dashboard/CommandsDashboard.php b/library/Director/Dashboard/CommandsDashboard.php new file mode 100644 index 0000000..13f4e42 --- /dev/null +++ b/library/Director/Dashboard/CommandsDashboard.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class CommandsDashboard extends Dashboard +{ + protected $dashletNames = array( + 'CheckCommands', + 'ExternalCheckCommands', + // 'NotificationCommands', + // 'ExternalNotificationCommands', + 'CommandTemplates', + ); + + public function getTitle() + { + return $this->translate('Manage your Icinga Commands'); + } + + public function getDescription() + { + return $this->translate( + 'Define Check-, Notification- or Event-Commands. Command definitions' + . ' are the glue between your Host- and Service-Checks and the Check' + . ' plugins on your Monitoring (or monitored) systems' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['hosts', 'services', 'commands'] + ); + } +} diff --git a/library/Director/Dashboard/Dashboard.php b/library/Director/Dashboard/Dashboard.php new file mode 100644 index 0000000..de8970c --- /dev/null +++ b/library/Director/Dashboard/Dashboard.php @@ -0,0 +1,305 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +use Exception; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Restriction\HostgroupRestriction; +use Icinga\Module\Director\Dashboard\Dashlet\Dashlet; +use Icinga\Module\Director\Db; +use Icinga\Web\Widget\Tab; +use ipl\Html\ValidHtml; +use Zend_Db_Select as ZfSelect; + +abstract class Dashboard extends HtmlDocument +{ + use TranslationHelper; + + protected $name; + + /** @var Dashlet[] */ + protected $dashlets; + + protected $dashletNames; + + /** @var Db */ + protected $db; + + final private function __construct() + { + } + + /** + * @param $name + * @param Db $db + * + * @return self + */ + public static function loadByName($name, Db $db) + { + $class = __NAMESPACE__ . '\\' . ucfirst($name) . 'Dashboard'; + $dashboard = new $class(); + $dashboard->db = $db; + $dashboard->name = $name; + return $dashboard; + } + + public static function exists($name) + { + return class_exists(__NAMESPACE__ . '\\' . ucfirst($name) . 'Dashboard'); + } + + /** + * @param $description + * @return $this + */ + protected function addDescription($description) + { + if ($description instanceof ValidHtml) { + $this->add(Html::tag('p', $description)); + } elseif ($description !== null) { + $this->add(Html::tag( + 'p', + null, + HtmlString::create(nl2br(Html::escape($description))) + )); + } + + return $this; + } + + public function render() + { + $this + ->setSeparator("\n") + ->add(Html::tag('h1', null, $this->getTitle())) + ->addDescription($this->getDescription()) + ->add($this->renderDashlets()); + + return parent::render(); + } + + public function renderDashlets() + { + $ul = Html::tag('ul', [ + 'class' => 'main-actions', + 'data-base-target' => '_next' + ]); + + foreach ($this->dashlets() as $dashlet) { + if ($dashlet->shouldBeShown()) { + $ul->add($dashlet); + } + } + + return $ul; + } + + public function getName() + { + return $this->name; + } + + abstract public function getTitle(); + + public function getDescription() + { + return null; + } + + public function getTabs() + { + $lName = $this->getName(); + $tabs = new Tabs(); + $tabs->add($lName, new Tab([ + 'label' => $this->translate(ucfirst($this->getName())), + 'url' => 'director/dashboard', + 'urlParams' => ['name' => $lName] + ])); + + return $tabs; + } + + protected function createTabsForDashboards($names) + { + $tabs = new Tabs(); + foreach ($names as $name) { + $dashboard = Dashboard::loadByName($name, $this->getDb()); + if ($dashboard->isAvailable()) { + $tabs->add($name, $this->createTabForDashboard($dashboard)); + } + } + + return $tabs; + } + + protected function createTabForDashboard(Dashboard $dashboard) + { + $name = $dashboard->getName(); + return new Tab([ + 'label' => $this->translate(ucfirst($name)), + 'url' => 'director/dashboard', + 'urlParams' => ['name' => $name] + ]); + } + + public function count() + { + return count($this->dashlets()); + } + + public function isAvailable() + { + return $this->count() > 0; + } + + public function getDb() + { + return $this->db; + } + + public function dashlets() + { + if ($this->dashlets === null) { + $this->loadDashlets(); + $this->fetchDashletSummaries(); + } + + return $this->dashlets; + } + + public function loadDashlets() + { + $names = $this->getDashletNames(); + + if (empty($names)) { + $this->dashlets = array(); + } else { + $this->dashlets = Dashlet::loadByNames( + $this->dashletNames, + $this->getDb() + ); + } + } + + public function getDashletNames() + { + return $this->dashletNames; + } + + protected function fetchDashletSummaries() + { + $types = array(); + foreach ($this->dashlets as $dashlet) { + foreach ($dashlet->listRequiredStats() as $objectType) { + $types[$objectType] = $objectType; + } + } + + if (empty($types)) { + return; + } + + try { + $stats = $this->getObjectSummary($types); + } catch (Exception $e) { + $stats = array(); + } + + $failing = array(); + foreach ($this->dashlets as $key => $dashlet) { + foreach ($dashlet->listRequiredStats() as $objectType) { + if (array_key_exists($objectType, $stats)) { + $dashlet->addStats($objectType, $stats[$objectType]); + } else { + $failing[] = $key; + } + } + } + + foreach ($failing as $key) { + unset($this->dashlets[$key]); + } + } + + public function getObjectSummary($types) + { + $queries = array(); + + foreach ($types as $type) { + $queries[] = $this->makeSummaryQuery($type); + } + $query = $this->db->select()->union($queries, ZfSelect::SQL_UNION_ALL); + + $result = array(); + foreach ($this->db->fetchAll($query) as $row) { + $result[$row->icinga_type] = $row; + } + + return $result; + } + + protected function makeSummaryQuery($type) + { + $columns = array( + 'icinga_type' => "('" . $type . "')", + 'cnt_object' => $this->getCntSql('object'), + 'cnt_template' => $this->getCntSql('template'), + 'cnt_external' => $this->getCntSql('external_object'), + 'cnt_apply' => $this->getCntSql('apply'), + 'cnt_total' => 'COUNT(*)', + ); + + if ($this->db->isPgsql()) { + $dummy = IcingaObject::createByType($type); + if (! $dummy->supportsApplyRules()) { + $columns['cnt_apply'] = '(0)'; + } + } + + $query = $this->db->getDbAdapter()->select()->from( + array('o' => 'icinga_' . $type), + $columns + ); + + return $this->applyRestrictions($type, $query); + } + + protected function applyRestrictions($type, $query) + { + switch ($type) { + case 'host': + case 'hostgroup': + $r = new HostgroupRestriction($this->getDb(), $this->getAuth()); + $r->applyToQuery($query); + break; + } + + return $query; + } + + protected function applyHostgroupRestrictions($query) + { + $restrictions = new HostgroupRestriction($this->getDb(), $this->getAuth()); + $restrictions->applyToHostGroupsQuery($query); + } + + protected function getAuth() + { + return Auth::getInstance(); + } + + protected function getCntSql($objectType) + { + return sprintf( + "COALESCE(SUM(CASE WHEN o.object_type = '%s' THEN 1 ELSE 0 END), 0)", + $objectType + ); + } +} diff --git a/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php b/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php new file mode 100644 index 0000000..9794986 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ActivityLogDashlet extends Dashlet +{ + protected $icon = 'book'; + + public function getTitle() + { + return $this->translate('Activity Log'); + } + + public function getSummary() + { + return $this->translate( + 'Wondering about what changed why? Track your changes!' + ); + } + + public function listCssClasses() + { + return 'state-ok'; + } + + public function getUrl() + { + return 'director/config/activities'; + } + + public function listRequiredPermissions() + { + return array('director/audit'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php b/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php new file mode 100644 index 0000000..419859d --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ApiUserObjectDashlet extends Dashlet +{ + protected $icon = 'lock-open-alt'; + + protected $requiredStats = array('apiuser'); + + public function getTitle() + { + return $this->translate('Icinga Api users'); + } + + public function getUrl() + { + return 'director/apiusers'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/BasketDashlet.php b/library/Director/Dashboard/Dashlet/BasketDashlet.php new file mode 100644 index 0000000..10f2b81 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/BasketDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class BasketDashlet extends Dashlet +{ + protected $icon = 'tag'; + + public function getTitle() + { + return $this->translate('Configuration Baskets'); + } + + public function getSummary() + { + return $this->translate( + 'Preserve specific configuration objects in a specific state' + ); + } + + public function getUrl() + { + return 'director/baskets'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php b/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php new file mode 100644 index 0000000..65d8c8c --- /dev/null +++ b/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class CheckCommandsDashlet extends Dashlet +{ + protected $icon = 'wrench'; + + public function getSummary() + { + return $this->translate( + 'Manage definitions for your Commands that should be executed as' + . ' Check Plugins, Notifications or based on Events' + ); + } + + public function getTitle() + { + return $this->translate('Commands'); + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } + + public function getUrl() + { + return 'director/commands'; + } +} diff --git a/library/Director/Dashboard/Dashlet/ChoicesDashlet.php b/library/Director/Dashboard/Dashlet/ChoicesDashlet.php new file mode 100644 index 0000000..efdbba5 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ChoicesDashlet.php @@ -0,0 +1,41 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +abstract class ChoicesDashlet extends Dashlet +{ + protected $icon = 'flapping'; + + public function getTitle() + { + return $this->translate('Choices'); + } + + public function getSummary() + { + return $this->translate( + 'Combine multiple templates into meaningful Choices, making life' + . ' easier for your users' + ); + } + + protected function getType() + { + return strtolower(substr( + substr(get_called_class(), strlen(__NAMESPACE__) + 1), + 0, + - strlen('ChoicesDashlet') + )); + } + + public function getUrl() + { + + return 'director/templatechoices/' . $this->getType(); + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php b/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php new file mode 100644 index 0000000..083172e --- /dev/null +++ b/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class CommandObjectDashlet extends Dashlet +{ + protected $icon = 'wrench'; + + protected $requiredStats = array('command'); + + public function getTitle() + { + return $this->translate('Commands'); + } + + public function getUrl() + { + return 'director/dashboard?name=commands'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php new file mode 100644 index 0000000..512298a --- /dev/null +++ b/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class CommandTemplatesDashlet extends CheckCommandsDashlet +{ + protected $icon = 'cubes'; + + public function getSummary() + { + return $this->translate( + 'External Notification Commands have been defined in your local Icinga 2' + . ' Configuration.' + ); + } + + public function getTitle() + { + return $this->translate('Command Templates'); + } + + public function getUrl() + { + return 'director/commands/templates'; + } +} diff --git a/library/Director/Dashboard/Dashlet/CustomvarDashlet.php b/library/Director/Dashboard/Dashlet/CustomvarDashlet.php new file mode 100644 index 0000000..919c06b --- /dev/null +++ b/library/Director/Dashboard/Dashlet/CustomvarDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class CustomvarDashlet extends Dashlet +{ + protected $icon = 'keyboard'; + + public function getTitle() + { + return $this->translate('CustomVar Overview'); + } + + public function getSummary() + { + return $this->translate( + 'Get an overview of used CustomVars and their variants' + ); + } + + public function getUrl() + { + return 'director/data/vars'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/Dashlet.php b/library/Director/Dashboard/Dashlet/Dashlet.php new file mode 100644 index 0000000..f8bc708 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/Dashlet.php @@ -0,0 +1,239 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Icinga\Module\Director\Acl; +use Icinga\Module\Director\Db; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; + +abstract class Dashlet extends BaseHtmlElement +{ + use TranslationHelper; + + /** @var Db */ + protected $db; + + protected $tag = 'li'; + + protected $icon = 'help'; + + protected $stats; + + protected $requiredStats = []; + + public function __construct(Db $db) + { + $this->db = $db; + } + + /** + * @return string[] + */ + public function listRequiredStats() + { + return $this->requiredStats; + } + + public function addStats($type, $stats) + { + $this->stats[$type] = $stats; + } + + /** + * @param $name + * @param Db $db + * @return Dashlet + */ + public static function loadByName($name, Db $db) + { + /** @var Dashlet */ + $class = __NAMESPACE__ . '\\' . $name . 'Dashlet'; + return new $class($db); + } + + public static function loadByNames(array $names, Db $db) + { + $dashlets = []; + foreach ($names as $name) { + $dashlet = static::loadByName($name, $db); + + if ($dashlet->isAllowed()) { + $dashlets[] = $dashlet; + } + } + + return $dashlets; + } + + public function listCssClasses() + { + return []; + } + + public function getIconName() + { + return $this->icon; + } + + abstract public function getTitle(); + + abstract public function getUrl(); + + protected function assemble() + { + $this->add(Link::create([ + $this->getTitle(), + Icon::create($this->getIconName()), + Html::tag('p', null, $this->getSummary()) + ], $this->getUrl(), null, [ + 'class' => $this->listCssClasses() + ])); + } + + public function listRequiredPermissions() + { + return array($this->getUrl()); + } + + public function isAllowed() + { + $acl = Acl::instance(); + foreach ($this->listRequiredPermissions() as $perm) { + if (! $acl->hasPermission($perm)) { + return false; + } + } + + return true; + } + + public function shouldBeShown() + { + return true; + } + + public function getSummary() + { + $result = ''; + if (! empty($this->requiredStats)) { + reset($this->requiredStats); + $result .= $this->statSummary(current($this->requiredStats)); + } + + return $result; + } + + public function getStats($type, $name = null) + { + if ($name === null) { + return $this->stats[$type]; + } else { + return $this->stats[$type]->{'cnt_' . $name}; + } + } + + protected function getTemplateSummaryText($type) + { + $cnt = (int) $this->stats[$type]->cnt_template; + + if ($cnt === 0) { + return $this->translate('No template has been defined yet'); + } + + if ($cnt === 1) { + return $this->translate('One template has been defined'); + } + + return sprintf( + $this->translate('%d templates have been defined'), + $cnt + ); + } + + protected function getApplySummaryText($type) + { + $cnt = (int) $this->stats[$type]->cnt_apply; + + if ($cnt === 0) { + return $this->translate('No apply rule has been defined yet'); + } + + if ($cnt === 1) { + return $this->translate('One apply rule has been defined'); + } + + return sprintf( + $this->translate('%d apply rules have been defined'), + $cnt + ); + } + + protected function statSummary($type) + { + $stat = $this->stats[$type]; + + if ((int) $stat->cnt_total === 0) { + return $this->translate('No object has been defined yet'); + } + + if ((int) $stat->cnt_total === 1) { + if ($stat->cnt_template > 0) { + $msg = $this->translate('One template has been defined'); + } elseif ($stat->cnt_external > 0) { + $msg = $this->translate( + 'One external object has been defined, it will not be deployed' + ); + } else { + $msg = $this->translate('One object has been defined'); + } + } else { + $msg = sprintf( + $this->translate('%d objects have been defined'), + $stat->cnt_total + ); + } + + $extra = array(); + if ($stat->cnt_total !== $stat->cnt_object) { + if ($stat->cnt_template > 0) { + $extra[] = sprintf( + $this->translate('%d of them are templates'), + $stat->cnt_template + ); + } + + if ($stat->cnt_external > 0) { + $extra[] = sprintf( + $this->translate( + '%d have been externally defined and will not be deployed' + ), + $stat->cnt_external + ); + } + } + + if (array_key_exists($type . 'group', $this->stats)) { + $groupstat = $this->stats[$type . 'group']; + if ((int) $groupstat->cnt_total === 0) { + $extra[] = $this->translate('no related group exists'); + } elseif ((int) $groupstat->cnt_total === 1) { + $extra[] = $this->translate('one related group exists'); + } else { + $extra[] = sprintf( + $this->translate('%s related group objects have been created'), + $groupstat->cnt_total + ); + } + } + + if (empty($extra)) { + return $msg; + } + + return $msg . ', ' . implode(', ', $extra); + } +} diff --git a/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php b/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php new file mode 100644 index 0000000..6efb4ca --- /dev/null +++ b/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class DatafieldCategoryDashlet extends Dashlet +{ + protected $icon = 'th-list'; + + public function getTitle() + { + return $this->translate('Data Field Categories'); + } + + public function getSummary() + { + return $this->translate( + 'Categories bring structure to your Data Fields' + ); + } + + public function getUrl() + { + return 'director/data/fieldcategories'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/DatafieldDashlet.php b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php new file mode 100644 index 0000000..03f2d8d --- /dev/null +++ b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class DatafieldDashlet extends Dashlet +{ + protected $icon = 'edit'; + + public function getTitle() + { + return $this->translate('Define Data Fields'); + } + + public function getSummary() + { + return $this->translate( + 'Data fields make sure that configuration fits your rules' + ); + } + + public function getUrl() + { + return 'director/data/fields'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/DatalistDashlet.php b/library/Director/Dashboard/Dashlet/DatalistDashlet.php new file mode 100644 index 0000000..bdf179f --- /dev/null +++ b/library/Director/Dashboard/Dashlet/DatalistDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class DatalistDashlet extends Dashlet +{ + protected $icon = 'sort-name-up'; + + public function getTitle() + { + return $this->translate('Provide Data Lists'); + } + + public function getSummary() + { + return $this->translate( + 'Provide data lists to make life easier for your users' + ); + } + + public function getUrl() + { + return 'director/data/lists'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php b/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php new file mode 100644 index 0000000..47a18aa --- /dev/null +++ b/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class DependencyObjectDashlet extends Dashlet +{ + protected $icon = 'sitemap'; + + protected $requiredStats = array('dependency'); + + public function getTitle() + { + return $this->translate('Dependencies'); + } + + public function getSummary() + { + return $this->translate('Object dependency relationships.') + . ' ' . parent::getSummary(); + } + + public function getUrl() + { + return 'director/dependencies/applyrules'; + } +} diff --git a/library/Director/Dashboard/Dashlet/DeploymentDashlet.php b/library/Director/Dashboard/Dashlet/DeploymentDashlet.php new file mode 100644 index 0000000..7a52793 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/DeploymentDashlet.php @@ -0,0 +1,114 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Exception; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; + +class DeploymentDashlet extends Dashlet +{ + protected $icon = 'wrench'; + + protected $undeployedActivities; + + protected $lastDeployment; + + public function getTitle() + { + return $this->translate('Config Deployment'); + } + + public function hasUndeployedActivities() + { + return $this->undeployedActivities() > 0; + } + + public function undeployedActivities() + { + if ($this->undeployedActivities === null) { + try { + $this->undeployedActivities = $this->db + ->countActivitiesSinceLastDeployedConfig(); + } catch (Exception $e) { + $this->undeployedActivities = 0; + } + } + + return $this->undeployedActivities; + } + + public function lastDeploymentFailed() + { + return ! $this->lastDeployment()->succeeded(); + } + + public function lastDeploymentPending() + { + return $this->lastDeployment()->isPending(); + } + + public function listCssClasses() + { + try { + if ($this->lastDeploymentFailed()) { + return array('state-critical'); + } elseif ($this->lastDeploymentPending()) { + return array('state-pending'); + } elseif ($this->hasUndeployedActivities()) { + return array('state-warning'); + } else { + return array('state-ok'); + } + } catch (Exception $e) { + return null; + } + } + + protected function lastDeployment() + { + if ($this->lastDeployment === null) { + $this->lastDeployment = DirectorDeploymentLog::loadLatest($this->db); + } + + return $this->lastDeployment; + } + + public function getSummary() + { + $msgs = array(); + $cnt = $this->undeployedActivities(); + + try { + if ($this->lastDeploymentFailed()) { + $msgs[] = $this->translate('The last deployment did not succeed'); + } elseif ($this->lastDeploymentPending()) { + $msgs[] = $this->translate('The last deployment is currently pending'); + } + } catch (Exception $e) { + } + + if ($cnt === 0) { + $msgs[] = $this->translate('There are no pending changes'); + } else { + $msgs[] = sprintf( + $this->translate( + 'A total of %d config changes happened since your last' + . ' deployed config has been rendered' + ), + $cnt + ); + } + + return implode('. ', $msgs) . '.'; + } + + public function getUrl() + { + return 'director/config/deployments'; + } + + public function listRequiredPermissions() + { + return array('director/deploy'); + } +} diff --git a/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php b/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php new file mode 100644 index 0000000..9dd9467 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php @@ -0,0 +1,63 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Exception; + +class EndpointObjectDashlet extends Dashlet +{ + protected $icon = 'cloud'; + + protected $requiredStats = array('endpoint'); + + protected $hasDeploymentEndpoint; + + public function getTitle() + { + return $this->translate('Endpoints'); + } + + public function getUrl() + { + return 'director/endpoints'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } + + protected function hasDeploymentEndpoint() + { + if ($this->hasDeploymentEndpoint === null) { + try { + $this->hasDeploymentEndpoint = $this->db->hasDeploymentEndpoint(); + } catch (Exception $e) { + return false; + } + } + + return $this->hasDeploymentEndpoint; + } + + public function listCssClasses() + { + if (! $this->hasDeploymentEndpoint()) { + return 'state-critical'; + } + + return null; + } + + public function getSummary() + { + $msg = parent::getSummary(); + if (! $this->hasDeploymentEndpoint()) { + $msg .= '. ' . $this->translate( + 'None could be used for deployments right now' + ); + } + + return $msg; + } +} diff --git a/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php b/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php new file mode 100644 index 0000000..2711fb9 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ExternalCheckCommandsDashlet extends CheckCommandsDashlet +{ + protected $icon = 'download'; + + public function getSummary() + { + return $this->translate( + 'External Commands have been defined in your local Icinga 2' + . ' Configuration.' + ); + } + + public function getTitle() + { + return $this->translate('External Commands'); + } + + public function getUrl() + { + return 'director/commands?type=external_object'; + } +} diff --git a/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php b/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php new file mode 100644 index 0000000..435d0cb --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php @@ -0,0 +1,21 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ExternalNotificationCommandsDashlet extends CheckCommandsDashlet +{ + protected $icon = 'wrench'; + + public function getSummary() + { + return $this->translate( + 'External Notification Commands have been defined in your local Icinga 2' + . ' Configuration.' + ); + } + + public function getTitle() + { + return $this->translate('External Notification Commands'); + } +} diff --git a/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php b/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php new file mode 100644 index 0000000..98bfe32 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php @@ -0,0 +1,7 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class HostChoicesDashlet extends ChoicesDashlet +{ +} diff --git a/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php b/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php new file mode 100644 index 0000000..5d3b25f --- /dev/null +++ b/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class HostGroupsDashlet extends Dashlet +{ + protected $icon = 'tags'; + + public function getTitle() + { + return $this->translate('Host Groups'); + } + + public function getSummary() + { + return $this->translate( + 'Define Host Groups to give your configuration more structure. They' + . ' are useful for Dashboards, Notifications or Restrictions' + ); + } + + public function getUrl() + { + return 'director/hostgroups'; + } + + public function listRequiredPermissions() + { + return array('director/hostgroups'); + } +} diff --git a/library/Director/Dashboard/Dashlet/HostObjectDashlet.php b/library/Director/Dashboard/Dashlet/HostObjectDashlet.php new file mode 100644 index 0000000..10cff94 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/HostObjectDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class HostObjectDashlet extends Dashlet +{ + protected $icon = 'host'; + + protected $requiredStats = array('host', 'hostgroup'); + + public function getTitle() + { + return $this->translate('Host objects'); + } + + public function listRequiredPermissions() + { + return ['director/hosts']; + } + + public function getUrl() + { + return 'director/dashboard?name=hosts'; + } +} diff --git a/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php new file mode 100644 index 0000000..09bed17 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class HostTemplatesDashlet extends Dashlet +{ + protected $icon = 'cubes'; + + public function getTitle() + { + return $this->translate('Host Templates'); + } + + public function getSummary() + { + return $this->translate( + 'Manage your Host Templates. Use Fields to make it easy for' + . ' your users to get them customized.' + ); + } + + public function getUrl() + { + return 'director/hosts/templates'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/HostsDashlet.php b/library/Director/Dashboard/Dashlet/HostsDashlet.php new file mode 100644 index 0000000..39c1421 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/HostsDashlet.php @@ -0,0 +1,32 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class HostsDashlet extends Dashlet +{ + protected $icon = 'host'; + + public function getTitle() + { + return $this->translate('Hosts'); + } + + public function getSummary() + { + return $this->translate( + 'This is where you add all your servers, containers, network or' + . ' sensor devices - and much more. Every subject worth to be' + . ' monitored' + ); + } + + public function getUrl() + { + return 'director/hosts'; + } + + public function listRequiredPermissions() + { + return ['director/hosts']; + } +} diff --git a/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php b/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php new file mode 100644 index 0000000..302c1ed --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Exception; +use Icinga\Module\Director\Objects\ImportSource; + +class ImportSourceDashlet extends Dashlet +{ + protected $icon = 'database'; + + public function getTitle() + { + return $this->translate('Import data sources'); + } + + public function listCssClasses() + { + try { + return $this->fetchStateClass(); + } catch (Exception $e) { + return 'state-critical'; + } + } + + public function getSummary() + { + return $this->translate( + 'Define and manage imports from various data sources' + ); + } + + protected function fetchStateClass() + { + $srcs = ImportSource::loadAll($this->db); + if (count($srcs) > 0) { + $state = 'state-ok'; + } else { + $state = null; + } + + foreach ($srcs as $src) { + if ($src->import_state !== 'in-sync') { + if ($src->import_state === 'failing') { + $state = 'state-critical'; + break; + } else { + $state = 'state-warning'; + } + } + } + + return $state; + } + + public function getUrl() + { + return 'director/importsources'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php b/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php new file mode 100644 index 0000000..328df72 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class InfrastructureDashlet extends Dashlet +{ + protected $icon = 'cloud'; + + public function getTitle() + { + return $this->translate('Icinga Infrastructure'); + } + + public function getSummary() + { + return $this->translate( + 'Manage your Icinga 2 infrastructure: Masters, Zones, Satellites and more' + ); + } + + public function getUrl() + { + return 'director/dashboard?name=infrastructure'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/JobDashlet.php b/library/Director/Dashboard/Dashlet/JobDashlet.php new file mode 100644 index 0000000..d7452e0 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/JobDashlet.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Exception; +use Icinga\Module\Director\Objects\DirectorJob; + +class JobDashlet extends Dashlet +{ + protected $icon = 'clock'; + + public function getTitle() + { + return $this->translate('Jobs'); + } + + public function listCssClasses() + { + try { + return $this->fetchStateClass(); + } catch (Exception $e) { + return 'state-critical'; + } + } + + public function getSummary() + { + return $this->translate( + 'Schedule and automate Import, Syncronization, Config Deployment,' + . ' Housekeeping and more' + ); + } + + protected function fetchStateClass() + { + /** @var DirectorJob[] $jobs */ + $jobs = DirectorJob::loadAll($this->db); + if (count($jobs) > 0) { + $state = 'state-ok'; + } else { + $state = null; + } + + foreach ($jobs as $job) { + if ($job->isPending()) { + $state = 'state-pending'; + } elseif (! $job->lastAttemptSucceeded()) { + $state = 'state-critical'; + break; + } + } + + return $state; + } + + public function getUrl() + { + return 'director/jobs'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/KickstartDashlet.php b/library/Director/Dashboard/Dashlet/KickstartDashlet.php new file mode 100644 index 0000000..09801f5 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/KickstartDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class KickstartDashlet extends Dashlet +{ + protected $icon = 'gauge'; + + public function getTitle() + { + return $this->translate('Kickstart Wizard'); + } + + public function getSummary() + { + return $this->translate( + 'This synchronizes Icinga Director to your Icinga 2 infrastructure.' + . ' A new run should be triggered on infrastructure changes' + ); + } + + public function getUrl() + { + return 'director/kickstart'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php b/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php new file mode 100644 index 0000000..e0b0443 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class NotificationApplyDashlet extends Dashlet +{ + protected $icon = 'bell'; + + protected $requiredStats = array('notification'); + + public function getTitle() + { + return $this->translate('Notifications'); + } + + public function getSummary() + { + return $this->translate( + 'Apply notifications with specific properties according to given rules.' + ) . ' ' . $this->getApplySummaryText('notification'); + } + + public function shouldBeShown() + { + return $this->getStats('notification', 'template') > 0; + } + + public function listRequiredPermissions() + { + return array('director/notifications'); + } + + public function getUrl() + { + return 'director/notifications/applyrules'; + } +} diff --git a/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php b/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php new file mode 100644 index 0000000..0a640ae --- /dev/null +++ b/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php @@ -0,0 +1,21 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class NotificationCommandsDashlet extends CheckCommandsDashlet +{ + protected $icon = 'wrench'; + + public function getSummary() + { + return $this->translate( + 'Notification Commands allow you to trigger any action you want when' + . ' a notification takes place' + ); + } + + public function getTitle() + { + return $this->translate('Notification Commands'); + } +} diff --git a/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php b/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php new file mode 100644 index 0000000..a58b5d0 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class NotificationTemplateDashlet extends Dashlet +{ + protected $icon = 'cubes'; + + protected $requiredStats = array('notification'); + + public function getTitle() + { + return $this->translate('Notification templates'); + } + + public function getSummary() + { + return $this->translate('Provide templates for your notifications.') + . ' ' . $this->getTemplateSummaryText('notification'); + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } + + public function getUrl() + { + return 'director/notifications/templates'; + } +} diff --git a/library/Director/Dashboard/Dashlet/NotificationsDashlet.php b/library/Director/Dashboard/Dashlet/NotificationsDashlet.php new file mode 100644 index 0000000..85610f0 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/NotificationsDashlet.php @@ -0,0 +1,33 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class NotificationsDashlet extends Dashlet +{ + protected $icon = 'bell'; + + protected $requiredStats = array('notification'); + + public function getTitle() + { + return $this->translate('Notifications'); + } + + public function getSummary() + { + return $this->translate( + 'Schedule your notifications. Define who should be notified, when,' + . ' and for which kind of problem' + ); + } + + public function listRequiredPermissions() + { + return array('director/notifications'); + } + + public function getUrl() + { + return 'director/dashboard?name=notifications'; + } +} diff --git a/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php b/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php new file mode 100644 index 0000000..45bcfa2 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ScheduledDowntimeApplyDashlet extends Dashlet +{ + protected $icon = 'plug'; + + protected $requiredStats = ['scheduled_downtime']; + + public function getTitle() + { + return $this->translate('Scheduled Downtimes'); + } + + public function listRequiredPermissions() + { + return array('director/scheduled-downtimes'); + } + + public function getUrl() + { + return 'director/scheduled-downtimes/applyrules'; + } +} diff --git a/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php b/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php new file mode 100644 index 0000000..32b1cfa --- /dev/null +++ b/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class SelfServiceDashlet extends Dashlet +{ + protected $icon = 'chat'; + + public function getTitle() + { + return $this->translate('Self Service API'); + } + + public function getSummary() + { + return $this->translate( + 'Icinga Director offers a Self Service API, allowing new Icinga' + . ' nodes to register themselves' + ); + } + + public function getUrl() + { + return 'director/settings/self-service'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php new file mode 100644 index 0000000..b4bee04 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ServiceApplyRulesDashlet extends Dashlet +{ + protected $icon = 'resize-full-alt'; + + public function getTitle() + { + return $this->translate('Service Apply Rules'); + } + + public function getSummary() + { + return $this->translate( + 'Using Apply Rules a Service can be applied to multiple hosts at once,' + . ' based on filters dealing with any combination of their properties' + ); + } + + public function getUrl() + { + return 'director/services/applyrules'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php new file mode 100644 index 0000000..ff23321 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php @@ -0,0 +1,7 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ServiceChoicesDashlet extends ChoicesDashlet +{ +} diff --git a/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php b/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php new file mode 100644 index 0000000..ad47768 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ServiceGroupsDashlet extends Dashlet +{ + protected $icon = 'tags'; + + public function getTitle() + { + return $this->translate('Service Groups'); + } + + public function getSummary() + { + return $this->translate( + 'Defining Service Groups get more structure. Great for Dashboards.' + . ' Notifications and Permissions might be based on groups.' + ); + } + + public function getUrl() + { + return 'director/servicegroups'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php b/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php new file mode 100644 index 0000000..01fb800 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Icinga\Module\Director\Acl; + +class ServiceObjectDashlet extends Dashlet +{ + protected $icon = 'services'; + + protected $requiredStats = array('service', 'servicegroup'); + + public function getTitle() + { + return $this->translate('Monitored Services'); + } + + public function getUrl() + { + return 'director/dashboard?name=services'; + } + + public function listRequiredPermissions() + { + return ['director/services']; + } + + public function isAllowed() + { + $acl = Acl::instance(); + return $acl->hasPermission('director/services') + || $acl->hasPermission('director/service_sets'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php b/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php new file mode 100644 index 0000000..f971d42 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ServiceSetsDashlet extends Dashlet +{ + protected $icon = 'services'; + + public function getTitle() + { + return $this->translate('Service Sets'); + } + + public function getSummary() + { + return $this->translate( + 'Grouping your Services into Sets allow you to quickly assign services' + . ' often used together in a single operation all at once' + ); + } + + public function getUrl() + { + return 'director/services/sets'; + } + + public function listRequiredPermissions() + { + return array('director/servicesets'); + } +} diff --git a/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php new file mode 100644 index 0000000..62d1b41 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ServiceTemplatesDashlet extends Dashlet +{ + protected $icon = 'cubes'; + + public function getTitle() + { + return $this->translate('Service Templates'); + } + + public function getSummary() + { + return $this->translate( + 'Manage your Service Templates. Use Fields to make it easy for' + . ' your users to get them customized.' + ); + } + + public function getUrl() + { + return 'director/services/templates'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/SettingsDashlet.php b/library/Director/Dashboard/Dashlet/SettingsDashlet.php new file mode 100644 index 0000000..716e565 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/SettingsDashlet.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class SettingsDashlet extends Dashlet +{ + protected $icon = 'edit'; + + public function getTitle() + { + return $this->translate('Director Settings'); + } + + public function getSummary() + { + return $this->translate( + 'Tweak some global Director settings' + ); + } + + public function getUrl() + { + return 'director/config/settings'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php b/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php new file mode 100644 index 0000000..297b3f8 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class SingleServicesDashlet extends Dashlet +{ + protected $icon = 'service'; + + public function getTitle() + { + return $this->translate('Single Services'); + } + + public function getSummary() + { + return $this->translate( + 'Here you can find all single services directly attached to single' + . ' hosts' + ); + } + + public function getUrl() + { + return 'director/services'; + } + + public function listRequiredPermissions() + { + return array('director/services'); + } +} diff --git a/library/Director/Dashboard/Dashlet/SyncDashlet.php b/library/Director/Dashboard/Dashlet/SyncDashlet.php new file mode 100644 index 0000000..4ac689a --- /dev/null +++ b/library/Director/Dashboard/Dashlet/SyncDashlet.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use Exception; +use Icinga\Module\Director\Objects\SyncRule; + +class SyncDashlet extends Dashlet +{ + protected $icon = 'flapping'; + + public function getTitle() + { + return $this->translate('Synchronize'); + } + + public function listCssClasses() + { + try { + return $this->fetchStateClass(); + } catch (Exception $e) { + return 'state-critical'; + } + } + + public function getSummary() + { + return $this->translate( + 'Define how imported data should be synchronized with Icinga' + ); + } + + protected function fetchStateClass() + { + $syncs = SyncRule::loadAll($this->db); + if (count($syncs) > 0) { + $state = 'state-ok'; + } else { + $state = null; + } + + foreach ($syncs as $sync) { + if ($sync->sync_state !== 'in-sync') { + if ($sync->sync_state === 'failing') { + $state = 'state-critical'; + break; + } else { + $state = 'state-warning'; + } + } + } + + return $state; + } + + public function getUrl() + { + return 'director/syncrules'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php new file mode 100644 index 0000000..ba4c1db --- /dev/null +++ b/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php @@ -0,0 +1,28 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use DirectoryIterator; +use Icinga\Exception\ProgrammingError; + +class TimeperiodObjectDashlet extends Dashlet +{ + protected $icon = 'calendar'; + + protected $requiredStats = array('timeperiod'); + + public function getTitle() + { + return $this->translate('Timeperiods'); + } + + public function getUrl() + { + return 'director/timeperiods'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php new file mode 100644 index 0000000..26339e4 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class TimeperiodTemplateDashlet extends Dashlet +{ + protected $icon = 'cubes'; + + protected $requiredStats = array('timeperiod'); + + public function getTitle() + { + return $this->translate('Timeperiod Templates'); + } + + public function getSummary() + { + return $this->translate('Provide templates for your TimePeriod objects.') + . ' ' . $this->getTemplateSummaryText('timeperiod'); + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } + + public function getUrl() + { + return 'director/timeperiods/templates'; + } +} diff --git a/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php new file mode 100644 index 0000000..5a54bec --- /dev/null +++ b/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class TimeperiodsDashlet extends Dashlet +{ + protected $icon = 'calendar'; + + protected $requiredStats = array('timeperiod'); + + public function getTitle() + { + return $this->translate('Timeperiods'); + } + + public function getUrl() + { + return 'director/dashboard?name=timeperiods'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php b/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php new file mode 100644 index 0000000..3fba4ba --- /dev/null +++ b/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class UserGroupsDashlet extends Dashlet +{ + protected $icon = 'tags'; + + public function getTitle() + { + return $this->translate('User Groups'); + } + + public function getSummary() + { + return $this->translate( + 'Defining Notifications for User Groups instead of single Users' + . ' gives more flexibility' + ); + } + + public function getUrl() + { + return 'director/usergroups'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/Dashlet/UserObjectDashlet.php b/library/Director/Dashboard/Dashlet/UserObjectDashlet.php new file mode 100644 index 0000000..463b84c --- /dev/null +++ b/library/Director/Dashboard/Dashlet/UserObjectDashlet.php @@ -0,0 +1,23 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +use DirectoryIterator; +use Icinga\Exception\ProgrammingError; + +class UserObjectDashlet extends Dashlet +{ + protected $icon = 'users'; + + protected $requiredStats = array('user', 'usergroup'); + + public function getTitle() + { + return $this->translate('Users / Contacts'); + } + + public function getUrl() + { + return 'director/users'; + } +} diff --git a/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php b/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php new file mode 100644 index 0000000..291ab05 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class UserTemplateDashlet extends Dashlet +{ + protected $icon = 'cubes'; + + protected $requiredStats = array('user'); + + public function getTitle() + { + return $this->translate('User Templates'); + } + + public function getSummary() + { + return $this->translate('Provide templates for your User objects.') + . ' ' . $this->getTemplateSummaryText('user'); + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } + + public function getUrl() + { + return 'director/users/templates'; + } +} diff --git a/library/Director/Dashboard/Dashlet/UsersDashlet.php b/library/Director/Dashboard/Dashlet/UsersDashlet.php new file mode 100644 index 0000000..43ddc26 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/UsersDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class UsersDashlet extends Dashlet +{ + protected $icon = 'users'; + + protected $requiredStats = array('user', 'usergroup'); + + public function getTitle() + { + return $this->translate('Users / Contacts'); + } + + public function listRequiredPermissions() + { + return array('director/users'); + } + + public function getUrl() + { + return 'director/dashboard?name=users'; + } +} diff --git a/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php b/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php new file mode 100644 index 0000000..ee789f2 --- /dev/null +++ b/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Dashboard\Dashlet; + +class ZoneObjectDashlet extends Dashlet +{ + protected $icon = 'globe'; + + protected $requiredStats = array('zone'); + + public function getTitle() + { + return $this->translate('Zones'); + } + + public function getUrl() + { + return 'director/zones'; + } + + public function listRequiredPermissions() + { + return array('director/admin'); + } +} diff --git a/library/Director/Dashboard/DataDashboard.php b/library/Director/Dashboard/DataDashboard.php new file mode 100644 index 0000000..36a807b --- /dev/null +++ b/library/Director/Dashboard/DataDashboard.php @@ -0,0 +1,18 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class DataDashboard extends Dashboard +{ + protected $dashletNames = [ + 'Datafield', + 'DatafieldCategory', + 'Datalist', + 'Customvar' + ]; + + public function getTitle() + { + return $this->translate('Do more with custom data'); + } +} diff --git a/library/Director/Dashboard/DeploymentDashboard.php b/library/Director/Dashboard/DeploymentDashboard.php new file mode 100644 index 0000000..6cd2005 --- /dev/null +++ b/library/Director/Dashboard/DeploymentDashboard.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class DeploymentDashboard extends Dashboard +{ + protected $dashletNames = array( + 'ActivityLog', + 'Deployment', + 'Infrastructure', + ); + + public function getTitle() + { + return $this->translate('Deploy configuration to your Icinga nodes'); + } +} diff --git a/library/Director/Dashboard/DirectorDashboard.php b/library/Director/Dashboard/DirectorDashboard.php new file mode 100644 index 0000000..47a17af --- /dev/null +++ b/library/Director/Dashboard/DirectorDashboard.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class DirectorDashboard extends Dashboard +{ + protected $dashletNames = array( + 'Settings', + 'Basket', + 'SelfService', + ); + + public function getTitle() + { + return $this->translate('Icinga Director Configuration'); + } +} diff --git a/library/Director/Dashboard/HostsDashboard.php b/library/Director/Dashboard/HostsDashboard.php new file mode 100644 index 0000000..281accb --- /dev/null +++ b/library/Director/Dashboard/HostsDashboard.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class HostsDashboard extends Dashboard +{ + protected $dashletNames = array( + 'Hosts', + 'HostTemplates', + 'HostGroups', + 'HostChoices', + ); + + public function getTitle() + { + return $this->translate('Manage your Icinga Hosts'); + } + + public function getDescription() + { + return $this->translate( + 'This is where you manage your Icinga 2 Host Checks. Host templates' + . ' are your main building blocks. You can bundle them to "choices",' + . ' allowing (or forcing) your users to choose among a given set of' + . ' preconfigured templates.' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['hosts', 'services', 'commands'] + ); + } +} diff --git a/library/Director/Dashboard/InfrastructureDashboard.php b/library/Director/Dashboard/InfrastructureDashboard.php new file mode 100644 index 0000000..2b369fc --- /dev/null +++ b/library/Director/Dashboard/InfrastructureDashboard.php @@ -0,0 +1,60 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +use gipfl\Web\Widget\Hint; +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Web\Tabs\InfraTabs; +use Icinga\Module\Director\Web\Widget\Documentation; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; + +class InfrastructureDashboard extends Dashboard +{ + protected $dashletNames = array( + 'Kickstart', + 'ApiUserObject', + 'EndpointObject', + 'ZoneObject', + ); + + public function getTitle() + { + return $this->translate('Manage your Icinga Infrastructure'); + } + + public function getDescription() + { + $documentation = new Documentation(Icinga::app(), Auth::getInstance()); + + $link = $documentation->getModuleLink( + $this->translate('documentation'), + 'director', + '24-Working-with-agents', + $this->translate('Working with Agents and Config Zones') + ); + return (new HtmlDocument())->add([ + $this->translate( + 'This is where you manage your Icinga 2 infrastructure. When adding' + . ' a new Icinga Master or Satellite please re-run the Kickstart' + . ' Helper once.' + ), + Hint::warning($this->translate( + 'When you feel the desire to manually create Zone or Endpoint' + . ' objects please rethink this twice. Doing so is mostly the wrong' + . ' way, might lead to a dead end, requiring quite some effort to' + . ' clean up the whole mess afterwards.' + )), + Html::sprintf( + $this->translate('Want to connect to your Icinga Agents? Have a look at our %s!'), + $link + ) + ]); + } + + public function getTabs() + { + return new InfraTabs($this->getAuth()); + } +} diff --git a/library/Director/Dashboard/NotificationsDashboard.php b/library/Director/Dashboard/NotificationsDashboard.php new file mode 100644 index 0000000..b7d72f5 --- /dev/null +++ b/library/Director/Dashboard/NotificationsDashboard.php @@ -0,0 +1,44 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class NotificationsDashboard extends Dashboard +{ + protected $dashletNames = [ + 'NotificationApply', + 'NotificationTemplate', + ]; + + public function getTitle() + { + return $this->translate('Schedule your notifications'); + } + + public function getDescription() + { + return $this->translate( + 'Notifications are sent when a host or service reaches a non-ok hard' + . ' state or recovers from such. One might also want to send them for' + . ' special events like when a Downtime starts, a problem gets' + . ' acknowledged and much more. You can send specific notifications' + . ' only within specific time periods, you can delay them and of course' + . ' re-notify at specific intervals.' + . "\n\n" + . ' Combine those possibilities in case you need to define escalation' + . ' levels, like notifying operators first and your management later on' + . ' in case the problem remains unhandled for a certain time.' + . "\n\n" + . ' You might send E-Mail or SMS, make phone calls or page on various' + . ' channels. You could also delegate notifications to external service' + . ' providers. The possibilities are endless, as you are allowed to' + . ' define as many custom notification commands as you want' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['notifications', 'users', 'timeperiods'] + ); + } +} diff --git a/library/Director/Dashboard/ObjectsDashboard.php b/library/Director/Dashboard/ObjectsDashboard.php new file mode 100644 index 0000000..02c2a4b --- /dev/null +++ b/library/Director/Dashboard/ObjectsDashboard.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class ObjectsDashboard extends Dashboard +{ + protected $dashletNames = array( + 'HostObject', + 'ServiceObject', + 'CommandObject', + ); + + public function getTitle() + { + return $this->translate('Define whatever you want to be monitored'); + } +} diff --git a/library/Director/Dashboard/ServicesDashboard.php b/library/Director/Dashboard/ServicesDashboard.php new file mode 100644 index 0000000..65c8f0a --- /dev/null +++ b/library/Director/Dashboard/ServicesDashboard.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class ServicesDashboard extends Dashboard +{ + protected $dashletNames = array( + 'SingleServices', + 'ServiceApplyRules', + 'ServiceTemplates', + 'ServiceGroups', + 'ServiceChoices', + 'ServiceSets' + ); + + public function getTitle() + { + return $this->translate('Manage your Icinga Service Checks'); + } + + public function getDescription() + { + return $this->translate( + 'This is where you manage your Icinga 2 Service Checks. Service' + . ' Templates are your base building blocks, Service Sets allow' + . ' you to assign multiple Services at once. Apply Rules make it' + . ' possible to assign Services based on Host properties. And' + . ' the list of all single Service Objects gives you the possibility' + . ' to still modify (or delete) many of them at once.' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['hosts', 'services', 'commands'] + ); + } +} diff --git a/library/Director/Dashboard/TimeperiodsDashboard.php b/library/Director/Dashboard/TimeperiodsDashboard.php new file mode 100644 index 0000000..9821b94 --- /dev/null +++ b/library/Director/Dashboard/TimeperiodsDashboard.php @@ -0,0 +1,33 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class TimeperiodsDashboard extends Dashboard +{ + protected $dashletNames = [ + 'TimeperiodObject', + 'TimeperiodTemplate', + ]; + + public function getTitle() + { + return $this->translate('Define custom Time Periods'); + } + + public function getDescription() + { + return $this->translate( + 'Want to define to execute specific checks only withing specific' + . ' time periods? Get mobile notifications only out of office hours,' + . ' but mail notifications all around the clock? Time Periods allow' + . ' you to tackle those and similar requirements.' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['notifications', 'users', 'timeperiods'] + ); + } +} diff --git a/library/Director/Dashboard/UsersDashboard.php b/library/Director/Dashboard/UsersDashboard.php new file mode 100644 index 0000000..036d149 --- /dev/null +++ b/library/Director/Dashboard/UsersDashboard.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Dashboard; + +class UsersDashboard extends Dashboard +{ + protected $dashletNames = [ + 'UserObject', + 'UserTemplate', + 'UserGroups', + ]; + + public function getTitle() + { + return $this->translate('Schedule your notifications'); + } + + public function getDescription() + { + return $this->translate( + 'This is where you manage your Icinga 2 User (Contact) objects. Try' + . ' to keep your User objects simply by movin complexity to your' + . ' templates. Bundle your users in groups and build Notifications' + . ' based on them. Running MS Active Directory or another central' + . ' User inventory? Stay away from fiddling with manual config, try' + . ' to automate all the things with Imports and related Sync Rules!' + ); + } + + public function getTabs() + { + return $this->createTabsForDashboards( + ['notifications', 'users', 'timeperiods'] + ); + } +} diff --git a/library/Director/Data/AssignFilterHelper.php b/library/Director/Data/AssignFilterHelper.php new file mode 100644 index 0000000..b0253cf --- /dev/null +++ b/library/Director/Data/AssignFilterHelper.php @@ -0,0 +1,160 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterOr; +use Icinga\Exception\NotImplementedError; + +/** + * Class ApplyFilterMatches + * + * A wrapper for Icinga Filter to evaluate filters against Director's objects + */ +class AssignFilterHelper +{ + /** @var Filter */ + protected $filter; + + public function __construct(Filter $filter) + { + $this->filter = $filter; + } + + /** + * @param object $object + * + * @return bool + * @throws NotImplementedError + */ + public function matches($object) + { + return $this->matchesPart($this->filter, $object); + } + + /** + * @param Filter $filter + * @param object $object + * + * @return bool + */ + public static function matchesFilter(Filter $filter, $object) + { + $helper = new static($filter); + return $helper->matches($object); + } + + /** + * @param Filter $filter + * @param object $object + * + * @return bool + * @throws NotImplementedError + */ + protected function matchesPart(Filter $filter, $object) + { + if ($filter->isChain()) { + return $this->matchesChain($filter, $object); + } elseif ($filter->isExpression()) { + /** @var FilterExpression $filter */ + return $this->matchesExpression($filter, $object); + } else { + return $filter->matches($object); + } + } + + /** + * @param Filter $filter + * @param object $object + * + * @return bool + * @throws NotImplementedError + */ + protected function matchesChain(Filter $filter, $object) + { + if ($filter instanceof FilterAnd) { + foreach ($filter->filters() as $f) { + if (! $this->matchesPart($f, $object)) { + return false; + } + } + + return true; + } elseif ($filter instanceof FilterOr) { + foreach ($filter->filters() as $f) { + if ($this->matchesPart($f, $object)) { + return true; + } + } + + return false; + } elseif ($filter instanceof FilterNot) { + foreach ($filter->filters() as $f) { + if ($this->matchesPart($f, $object)) { + return false; + } + } + + return true; + } else { + $class = \get_class($filter); + $parts = \preg_split('/\\\/', $class); + + throw new NotImplementedError( + 'Matching for Filter of type "%s" is not implemented', + \end($parts) + ); + } + } + + /** + * @param FilterExpression $filter + * @param object $object + * + * @return bool + */ + protected function matchesExpression(FilterExpression $filter, $object) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $expression = $filter->getExpression(); + + if ($sign === '=') { + if ($expression === true) { + return property_exists($object, $column) && ! empty($object->{$column}); + } elseif ($expression === false) { + return ! property_exists($object, $column) || empty($object->{$column}); + } elseif (is_string($expression) && strpos($expression, '*') !== false) { + if (! property_exists($object, $column) || empty($object->{$column})) { + return false; + } + $value = $object->{$column}; + + $parts = array(); + foreach (preg_split('~\*~', $expression) as $part) { + $parts[] = preg_quote($part); + } + // match() is case insensitive + $pattern = '/^' . implode('.*', $parts) . '$/i'; + + if (is_array($value)) { + foreach ($value as $candidate) { + if (preg_match($pattern, $candidate)) { + return true; + } + } + + return false; + } + + return (bool) preg_match($pattern, $value); + } + } + + // fallback to default behavior + return $filter->matches($object); + } +} diff --git a/library/Director/Data/DataArrayHelper.php b/library/Director/Data/DataArrayHelper.php new file mode 100644 index 0000000..442eb0f --- /dev/null +++ b/library/Director/Data/DataArrayHelper.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use InvalidArgumentException; +use function array_diff; +use function array_key_exists; +use function implode; +use function is_array; +use function is_object; + +class DataArrayHelper +{ + public static function wantArray($value) + { + if (is_object($value)) { + return (array) $value; + } + if (! is_array($value)) { + throw new InvalidDataException('Object', $value); + } + + return $value; + } + + public static function failOnUnknownProperties(array $values, array $knownProperties) + { + $unknownProperties = array_diff($knownProperties, array_keys($values)); + + if (! empty($unknownProperties)) { + throw new InvalidArgumentException('Unexpected properties: ' . implode(', ', $unknownProperties)); + } + } + + public static function requireProperties(array $value, array $properties) + { + $missing = []; + foreach ($properties as $property) { + if (! array_key_exists($property, $value)) { + $missing[] = $property; + } + } + + if (! empty($missing)) { + throw new InvalidArgumentException('Missing properties: ' . implode(', ', $missing)); + } + } +} diff --git a/library/Director/Data/Db/DbConnection.php b/library/Director/Data/Db/DbConnection.php new file mode 100644 index 0000000..146b0e8 --- /dev/null +++ b/library/Director/Data/Db/DbConnection.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Data\Db\DbConnection as IcingaDbConnection; +use Icinga\Module\Director\Db\DbUtil; +use RuntimeException; +use Zend_Db_Expr; + +class DbConnection extends IcingaDbConnection +{ + public function isMysql() + { + return $this->getDbType() === 'mysql'; + } + + public function isPgsql() + { + return $this->getDbType() === 'pgsql'; + } + + /** + * @deprecated + * @param ?string $binary + * @return Zend_Db_Expr|Zend_Db_Expr[]|null + */ + public function quoteBinary($binary) + { + return DbUtil::quoteBinaryLegacy($binary, $this->getDbAdapter()); + } + + public function binaryDbResult($value) + { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + public function hasPgExtension($name) + { + $db = $this->db(); + $query = $db->select()->from( + array('e' => 'pg_extension'), + array('cnt' => 'COUNT(*)') + )->where('extname = ?', $name); + + return (int) $db->fetchOne($query) === 1; + } +} diff --git a/library/Director/Data/Db/DbDataFormatter.php b/library/Director/Data/Db/DbDataFormatter.php new file mode 100644 index 0000000..d6e4eeb --- /dev/null +++ b/library/Director/Data/Db/DbDataFormatter.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use InvalidArgumentException; + +class DbDataFormatter +{ + public static function normalizeBoolean($value) + { + if ($value === 'y' || $value === '1' || $value === true || $value === 1) { + return 'y'; + } + if ($value === 'n' || $value === '0' || $value === false || $value === 0) { + return 'n'; + } + if ($value === '' || $value === null) { + return null; + } + + throw new InvalidArgumentException(sprintf( + 'Got invalid boolean: %s', + var_export($value, 1) + )); + } +} diff --git a/library/Director/Data/Db/DbObject.php b/library/Director/Data/Db/DbObject.php new file mode 100644 index 0000000..6ecae8b --- /dev/null +++ b/library/Director/Data/Db/DbObject.php @@ -0,0 +1,1487 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Data\InvalidDataException; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\UuidLookup; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use InvalidArgumentException; +use LogicException; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use RuntimeException; +use Zend_Db_Adapter_Abstract; +use Zend_Db_Exception; + +/** + * Base class for ... + */ +abstract class DbObject +{ + /** @var DbConnection $connection */ + protected $connection; + + /** @var string Table name. MUST be set when extending this class */ + protected $table; + + /** @var Zend_Db_Adapter_Abstract */ + protected $db; + + /** + * Default columns. MUST be set when extending this class. Each table + * column MUST be defined with a default value. Default value may be null. + * + * @var array + */ + protected $defaultProperties; + + /** + * Properties as loaded from db + */ + protected $loadedProperties; + + /** + * Whether at least one property has been modified + */ + protected $hasBeenModified = false; + + /** + * Whether this object has been loaded from db + */ + protected $loadedFromDb = false; + + /** + * Object properties + */ + protected $properties = array(); + + /** + * Property names that have been modified since object creation + */ + protected $modifiedProperties = array(); + + /** + * Unique key name, could be primary + */ + protected $keyName; + + /** + * Set this to an eventual autoincrementing column. May equal $keyName + */ + protected $autoincKeyName; + + /** @var string optional uuid column */ + protected $uuidColumn; + + /** @var bool forbid updates to autoinc values */ + protected $protectAutoinc = true; + + protected $binaryProperties = []; + + /** + * Filled with object instances when prefetchAll is used + */ + protected static $prefetched = array(); + + /** + * object_name => id map for prefetched objects + */ + protected static $prefetchedNames = array(); + + protected static $prefetchStats = array(); + + /** @var ?DbObjectStore */ + protected static $dbObjectStore; + + /** + * Constructor is not accessible and should not be overridden + */ + protected function __construct() + { + if ($this->table === null + || $this->keyName === null + || $this->defaultProperties === null + ) { + throw new LogicException("Someone extending this class didn't RTFM"); + } + + $this->properties = $this->defaultProperties; + $this->beforeInit(); + } + + public function getTableName() + { + return $this->table; + } + + /************************************************************************\ + * When extending this class one might want to override any of the * + * following hooks. Try to use them whenever possible, especially * + * instead of overriding other essential methods like store(). * + \************************************************************************/ + + /** + * One can override this to allow for cross checks and more before storing + * the object. Please note that the method is public and allows to check + * object consistence at any time. + * + * @return boolean Whether this object is valid + */ + public function validate() + { + return true; + } + + /** + * This is going to be executed before any initialization method takes * + * (load from DB, populate from Array...) takes place + * + * @return void + */ + protected function beforeInit() + { + } + + /** + * Will be executed every time an object has successfully been loaded from + * Database + * + * @return void + */ + protected function onLoadFromDb() + { + } + + /** + * Will be executed before an Object is going to be stored. In case you + * want to prevent the store() operation from taking place, please throw + * an Exception. + * + * @return void + */ + protected function beforeStore() + { + } + + /** + * Wird ausgeführt, nachdem ein Objekt erfolgreich gespeichert worden ist + * + * @return void + */ + protected function onStore() + { + } + + /** + * Wird ausgeführt, nachdem ein Objekt erfolgreich der Datenbank hinzu- + * gefügt worden ist + * + * @return void + */ + protected function onInsert() + { + } + + /** + * Wird ausgeführt, nachdem bestehendes Objekt erfolgreich der Datenbank + * geändert worden ist + * + * @return void + */ + protected function onUpdate() + { + } + + /** + * Wird ausgeführt, bevor ein Objekt gelöscht wird. Die Operation wird + * aber auf jeden Fall durchgeführt, außer man wirft eine Exception + * + * @return void + */ + protected function beforeDelete() + { + } + + /** + * Wird ausgeführt, nachdem bestehendes Objekt erfolgreich aud der + * Datenbank gelöscht worden ist + * + * @return void + */ + protected function onDelete() + { + } + + /** + * Set database connection + * + * @param DbConnection $connection Database connection + * + * @return self + */ + public function setConnection(DbConnection $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + + return $this; + } + + public static function setDbObjectStore(DbObjectStore $store) + { + self::$dbObjectStore = $store; + } + + /** + * Getter + * + * @param string $property Property + * + * @return mixed + */ + public function get($property) + { + $func = 'get' . ucfirst($property); + if (substr($func, -2) === '[]') { + $func = substr($func, 0, -2); + } + // TODO: id check avoids collision with getId. Rethink this. + if ($property !== 'id' && method_exists($this, $func)) { + return $this->$func(); + } + + $this->assertPropertyExists($property); + return $this->properties[$property]; + } + + public function getProperty($key) + { + $this->assertPropertyExists($key); + return $this->properties[$key]; + } + + protected function assertPropertyExists($key) + { + if (! array_key_exists($key, $this->properties)) { + throw new InvalidArgumentException(sprintf( + 'Trying to get invalid property "%s"', + $key + )); + } + + return $this; + } + + public function hasProperty($key) + { + if (array_key_exists($key, $this->properties)) { + return true; + } elseif ($key === 'id') { + // There is getId, would give false positive + return false; + } + + return $this->hasGetterForProperty($key); + } + + protected function hasGetterForProperty($key) + { + $func = 'get' . ucfirst($key); + if (\substr($func, -2) === '[]') { + $func = substr($func, 0, -2); + } + + return \method_exists($this, $func); + } + + protected function hasSetterForProperty($key) + { + $func = 'set' . ucfirst($key); + if (\substr($func, -2) === '[]') { + $func = substr($func, 0, -2); + } + + return \method_exists($this, $func); + } + + /** + * Generic setter + * + * @param string $key + * @param mixed $value + * + * @return self + */ + public function set($key, $value) + { + $key = (string) $key; + if ($value === '') { + $value = null; + } + + if (is_resource($value)) { + $value = stream_get_contents($value); + } + $func = 'validate' . ucfirst($key); + if (method_exists($this, $func) && $this->$func($value) !== true) { + throw new InvalidArgumentException(sprintf( + 'Got invalid value "%s" for "%s"', + $value, + $key + )); + } + $func = 'munge' . ucfirst($key); + if (method_exists($this, $func)) { + $value = $this->$func($value); + } + + $func = 'set' . ucfirst($key); + if (substr($func, -2) === '[]') { + $func = substr($func, 0, -2); + } + + if (method_exists($this, $func)) { + return $this->$func($value); + } + + if (! $this->hasProperty($key)) { + throw new InvalidArgumentException(sprintf( + 'Trying to set invalid key "%s"', + $key + )); + } + + if ((is_numeric($value) || is_string($value)) + && (string) $value === (string) $this->get($key) + ) { + return $this; + } + + if ($key === $this->getAutoincKeyName() && $this->hasBeenLoadedFromDb()) { + throw new InvalidArgumentException('Changing autoincremental key is not allowed'); + } + + return $this->reallySet($key, $value); + } + + protected function reallySet($key, $value) + { + if ($value === $this->properties[$key]) { + return $this; + } + if ($key === 'id' || substr($key, -3) === '_id') { + if ((int) $value === (int) $this->properties[$key]) { + return $this; + } + } + + if ($this->hasBeenLoadedFromDb()) { + if ($value === $this->loadedProperties[$key]) { + unset($this->modifiedProperties[$key]); + if (empty($this->modifiedProperties)) { + $this->hasBeenModified = false; + } + } else { + $this->hasBeenModified = true; + $this->modifiedProperties[$key] = true; + } + } else { + $this->hasBeenModified = true; + $this->modifiedProperties[$key] = true; + } + + $this->properties[$key] = $value; + return $this; + } + + /** + * Magic getter + * + * @param mixed $key + * + * @return mixed + */ + public function __get($key) + { + return $this->get($key); + } + + /** + * Magic setter + * + * @param string $key Key + * @param mixed $val Value + * + * @return void + */ + public function __set($key, $val) + { + $this->set($key, $val); + } + + /** + * Magic isset check + * + * @param string $key + * @return boolean + */ + public function __isset($key) + { + return array_key_exists($key, $this->properties); + } + + /** + * Magic unsetter + * + * @param string $key + * @return void + */ + public function __unset($key) + { + if (! array_key_exists($key, $this->properties)) { + throw new InvalidArgumentException('Trying to unset invalid key'); + } + $this->properties[$key] = $this->defaultProperties[$key]; + } + + /** + * Runs set() for every key/value pair of the given Array + * + * @param array $props Array of properties + * @return self + */ + public function setProperties($props) + { + if (! is_array($props)) { + throw new InvalidArgumentException(sprintf( + 'Array required, got %s', + gettype($props) + )); + } + foreach ($props as $key => $value) { + $this->set($key, $value); + } + return $this; + } + + /** + * Return an array with all object properties + * + * @return array + */ + public function getProperties() + { + //return $this->properties; + $res = array(); + foreach ($this->listProperties() as $key) { + $res[$key] = $this->get($key); + } + + return $res; + } + + protected function getPropertiesForDb() + { + return $this->properties; + } + + public function listProperties() + { + return array_keys($this->properties); + } + + public function getDefaultProperties() + { + return $this->defaultProperties; + } + + /** + * Return all properties that changed since object creation + * + * @return array + */ + public function getModifiedProperties() + { + $props = array(); + foreach (array_keys($this->modifiedProperties) as $key) { + if ($key === $this->autoincKeyName) { + if ($this->protectAutoinc) { + continue; + } elseif ($this->properties[$key] === null) { + continue; + } + } + + $props[$key] = $this->properties[$key]; + } + return $props; + } + + /** + * List all properties that changed since object creation + * + * @return array + */ + public function listModifiedProperties() + { + return array_keys($this->modifiedProperties); + } + + /** + * Whether this object has been modified + * + * @return bool + */ + public function hasBeenModified() + { + return $this->hasBeenModified; + } + + /** + * Whether the given property has been modified + * + * @param string $key Property name + * @return boolean + */ + protected function hasModifiedProperty($key) + { + return array_key_exists($key, $this->modifiedProperties); + } + + /** + * Unique key name + * + * @return string + */ + public function getKeyName() + { + return $this->keyName; + } + + /** + * Autoinc key name + * + * @return string + */ + public function getAutoincKeyName() + { + return $this->autoincKeyName; + } + + /** + * @return ?string + */ + public function getUuidColumn() + { + return $this->uuidColumn; + } + + /** + * @return bool + */ + public function hasUuidColumn() + { + return $this->uuidColumn !== null; + } + + /** + * @return \Ramsey\Uuid\UuidInterface + */ + public function getUniqueId() + { + if ($this->hasUuidColumn()) { + $binaryValue = $this->properties[$this->uuidColumn]; + if (is_resource($binaryValue)) { + throw new RuntimeException('Properties contain binary UUID, probably a programming error'); + } + if ($binaryValue === null) { + $uuid = Uuid::uuid4(); + $this->reallySet($this->uuidColumn, $uuid->getBytes()); + return $uuid; + } + + return Uuid::fromBytes($binaryValue); + } + + throw new InvalidArgumentException(sprintf('%s has no UUID column', $this->getTableName())); + } + + public function getKeyParams() + { + $params = array(); + $key = $this->getKeyName(); + if (is_array($key)) { + foreach ($key as $k) { + $params[$k] = $this->get($k); + } + } else { + $params[$key] = $this->get($this->keyName); + } + + return $params; + } + + /** + * Return the unique identifier + * + * // TODO: may conflict with ->id + * + * @throws InvalidArgumentException When key can not be calculated + * + * @return string|array + */ + public function getId() + { + if (is_array($this->keyName)) { + $id = array(); + foreach ($this->keyName as $key) { + if (isset($this->properties[$key])) { + $id[$key] = $this->properties[$key]; + } + } + + if (empty($id)) { + throw new InvalidArgumentException('Could not evaluate id for multi-column object!'); + } + + return $id; + } else { + if (isset($this->properties[$this->keyName])) { + return $this->properties[$this->keyName]; + } + } + return null; + } + + /** + * Get the autoinc value if set + * + * @return int + */ + public function getAutoincId() + { + if (isset($this->properties[$this->autoincKeyName])) { + return (int) $this->properties[$this->autoincKeyName]; + } + return null; + } + + protected function forgetAutoincId() + { + if (isset($this->properties[$this->autoincKeyName])) { + $this->properties[$this->autoincKeyName] = null; + } + + return $this; + } + + /** + * Liefert das benutzte Datenbank-Handle + * + * @return Zend_Db_Adapter_Abstract + */ + public function getDb() + { + return $this->db; + } + + public function hasConnection() + { + return $this->connection !== null; + } + + public function getConnection() + { + return $this->connection; + } + + /** + * Lädt einen Datensatz aus der Datenbank und setzt die entsprechenden + * Eigenschaften dieses Objekts + * + * @throws NotFoundError + * @return self + */ + protected function loadFromDb() + { + $select = $this->db->select()->from($this->table)->where($this->createWhere()); + $properties = $this->db->fetchRow($select); + + if (empty($properties)) { + if (is_array($this->getKeyName())) { + throw new NotFoundError( + 'Failed to load %s for %s', + $this->table, + $this->createWhere() + ); + } else { + throw new NotFoundError( + 'Failed to load %s "%s"', + $this->table, + $this->getLogId() + ); + } + } + + return $this->setDbProperties($properties); + } + + /** + * @param object|array $row + * @param Db $db + * @return self + */ + public static function fromDbRow($row, Db $db) + { + $self = (new static())->setConnection($db); + if (is_object($row)) { + return $self->setDbProperties((array) $row); + } + + if (is_array($row)) { + return $self->setDbProperties($row); + } + + throw new InvalidDataException('array or object', $row); + } + + protected function setDbProperties($properties) + { + foreach ($properties as $key => $val) { + if (! array_key_exists($key, $this->properties)) { + throw new LogicException(sprintf( + 'Trying to set invalid %s key "%s". DB schema change?', + $this->table, + $key + )); + } + if ($val === null) { + $this->properties[$key] = null; + } elseif (is_resource($val)) { + $this->properties[$key] = stream_get_contents($val); + } else { + $this->properties[$key] = (string) $val; + } + } + + $this->setBeingLoadedFromDb(); + $this->onLoadFromDb(); + return $this; + } + + public function setBeingLoadedFromDb() + { + $this->loadedFromDb = true; + $this->loadedProperties = $this->properties; + $this->hasBeenModified = false; + $this->modifiedProperties = []; + } + + public function setLoadedProperty($key, $value) + { + if ($this->hasBeenLoadedFromDb()) { + $this->set($key, $value); + $this->loadedProperties[$key] = $this->get($key); + } else { + throw new RuntimeException('Cannot set loaded property for new object'); + } + } + + public function getOriginalProperties() + { + return $this->loadedProperties; + } + + public function getOriginalProperty($key) + { + $this->assertPropertyExists($key); + if ($this->hasBeenLoadedFromDb()) { + return $this->loadedProperties[$key]; + } + + return null; + } + + public function resetProperty($key) + { + $this->set($key, $this->getOriginalProperty($key)); + if ($this->listModifiedProperties() === [$key]) { + $this->hasBeenModified = false; + } + + return $this; + } + + public function hasBeenLoadedFromDb() + { + return $this->loadedFromDb; + } + + /** + * Ändert den entsprechenden Datensatz in der Datenbank + * + * @return int Anzahl der geänderten Zeilen + * @throws \Zend_Db_Adapter_Exception + */ + protected function updateDb() + { + $properties = $this->getModifiedProperties(); + if (empty($properties)) { + // Fake true, we might have manually set this to "modified" + return true; + } + $this->quoteBinaryProperties($properties); + + // TODO: Remember changed data for audit and log + return $this->db->update( + $this->table, + $properties, + $this->createWhere() + ); + } + + /** + * Fügt der Datenbank-Tabelle einen entsprechenden Datensatz hinzu + * + * @return int Anzahl der betroffenen Zeilen + * @throws \Zend_Db_Adapter_Exception + */ + protected function insertIntoDb() + { + $properties = $this->getPropertiesForDb(); + if ($this->autoincKeyName !== null) { + if ($this->protectAutoinc || $properties[$this->autoincKeyName] === null) { + unset($properties[$this->autoincKeyName]); + } + } + if ($column = $this->getUuidColumn()) { + $properties[$column] = $this->getUniqueId()->getBytes(); + } + $this->quoteBinaryProperties($properties); + + return $this->db->insert($this->table, $properties); + } + + protected function quoteBinaryProperties(&$properties) + { + foreach ($properties as $key => $value) { + if ($this->isBinaryColumn($key)) { + $properties[$key] = $this->getConnection()->quoteBinary($value); + } + } + } + + protected function isBinaryColumn($column) + { + return in_array($column, $this->binaryProperties) || $this->getUuidColumn() === $column; + } + + /** + * Store object to database + * + * @param DbConnection $db + * @return bool Whether storing succeeded. Always true, throws otherwise + * @throws DuplicateKeyException + */ + public function store(DbConnection $db = null) + { + if ($db !== null) { + $this->setConnection($db); + } + + if ($this->validate() !== true) { + throw new InvalidArgumentException(sprintf( + '%s[%s] validation failed', + $this->table, + $this->getLogId() + )); + } + + if ($this->hasBeenLoadedFromDb() && ! $this->hasBeenModified()) { + return true; + } + + $this->beforeStore(); + $table = $this->table; + $id = $this->getId(); + + try { + if ($this->hasBeenLoadedFromDb()) { + if ($this->updateDb() !== false) { + $this->onUpdate(); + } else { + throw new RuntimeException(sprintf( + 'FAILED storing %s "%s"', + $table, + $this->getLogId() + )); + } + } else { + if ($id && $this->existsInDb()) { + $logId = '"' . $this->getLogId() . '"'; + + if ($autoId = $this->getAutoincId()) { + $logId .= sprintf(', %s=%s', $this->autoincKeyName, $autoId); + } + throw new DuplicateKeyException( + 'Trying to recreate %s (%s)', + $table, + $logId + ); + } + + if ($this->insertIntoDb()) { + if ($this->autoincKeyName && $this->getProperty($this->autoincKeyName) === null) { + if ($this->connection->isPgsql()) { + $this->properties[$this->autoincKeyName] = $this->db->lastInsertId( + $table, + $this->autoincKeyName + ); + } else { + $this->properties[$this->autoincKeyName] = $this->db->lastInsertId(); + } + } + // $this->log(sprintf('New %s "%s" has been stored', $table, $id)); + $this->onInsert(); + } else { + throw new RuntimeException(sprintf( + 'FAILED to store new %s "%s"', + $table, + $this->getLogId() + )); + } + } + } catch (Zend_Db_Exception $e) { + throw new RuntimeException(sprintf( + 'Storing %s[%s] failed: %s {%s}', + $this->table, + $this->getLogId(), + $e->getMessage(), + var_export($this->getProperties(), 1) // TODO: Remove properties + )); + } + + // Hint: order is differs from setBeingLoadedFromDb() as of the onStore hook + $this->modifiedProperties = []; + $this->hasBeenModified = false; + $this->loadedProperties = $this->properties; + $this->onStore(); + $this->loadedFromDb = true; + + return true; + } + + /** + * Delete item from DB + * + * @return int Affected rows + */ + protected function deleteFromDb() + { + return $this->db->delete( + $this->table, + $this->createWhere() + ); + } + + /** + * @param string $key + * @return self + * @throws InvalidArgumentException + */ + protected function setKey($key) + { + $keyname = $this->getKeyName(); + if (is_array($keyname)) { + if (! is_array($key)) { + throw new InvalidArgumentException(sprintf( + '%s has a multicolumn key, array required', + $this->table + )); + } + foreach ($keyname as $k) { + if (! array_key_exists($k, $key)) { + // We allow for null in multicolumn keys: + $key[$k] = null; + } + $this->set($k, $key[$k]); + } + } else { + $this->set($keyname, $key); + } + return $this; + } + + protected function existsInDb() + { + $result = $this->db->fetchRow( + $this->db->select()->from($this->table)->where($this->createWhere()) + ); + return $result !== false; + } + + public function createWhere() + { + if ($this->hasUuidColumn() && $this->properties[$this->uuidColumn] !== null) { + return $this->db->quoteInto( + sprintf('%s = ?', $this->getUuidColumn()), + $this->connection->quoteBinary($this->getUniqueId()->getBytes()) + ); + } + if ($id = $this->getAutoincId()) { + if ($originalId = $this->getOriginalProperty($this->autoincKeyName)) { + return $this->db->quoteInto( + sprintf('%s = ?', $this->autoincKeyName), + $originalId + ); + } + return $this->db->quoteInto( + sprintf('%s = ?', $this->autoincKeyName), + $id + ); + } + + $key = $this->getKeyName(); + + if (is_array($key) && ! empty($key)) { + $where = array(); + foreach ($key as $k) { + if ($this->hasBeenLoadedFromDb()) { + if ($this->loadedProperties[$k] === null) { + $where[] = sprintf('%s IS NULL', $k); + } else { + $where[] = $this->createQuotedWhere($k, $this->loadedProperties[$k]); + } + } else { + if ($this->properties[$k] === null) { + $where[] = sprintf('%s IS NULL', $k); + } else { + $where[] = $this->createQuotedWhere($k, $this->properties[$k]); + } + } + } + + return implode(' AND ', $where); + } else { + if ($this->hasBeenLoadedFromDb()) { + return $this->createQuotedWhere($key, $this->loadedProperties[$key]); + } else { + return $this->createQuotedWhere($key, $this->properties[$key]); + } + } + } + + protected function createQuotedWhere($column, $value) + { + return $this->db->quoteInto( + sprintf('%s = ?', $column), + $this->eventuallyQuoteBinary($value, $column) + ); + } + + protected function eventuallyQuoteBinary($value, $column) + { + if ($this->isBinaryColumn($column)) { + return $this->connection->quoteBinary($value); + } else { + return $value; + } + } + + protected function getLogId() + { + $id = $this->getId(); + if (is_array($id)) { + $logId = json_encode($id); + } else { + $logId = $id; + } + + if ($logId === null && $this->autoincKeyName) { + $logId = $this->getAutoincId(); + } + + return $logId; + } + + public function delete() + { + $table = $this->table; + + if (! $this->hasBeenLoadedFromDb()) { + throw new LogicException(sprintf( + 'Cannot delete %s "%s", it has not been loaded from Db', + $table, + $this->getLogId() + )); + } + + if (! $this->existsInDb()) { + throw new InvalidArgumentException(sprintf( + 'Cannot delete %s "%s", it does not exist', + $table, + $this->getLogId() + )); + } + $this->beforeDelete(); + if (! $this->deleteFromDb()) { + throw new RuntimeException(sprintf( + 'Deleting %s (%s) FAILED', + $table, + $this->getLogId() + )); + } + // $this->log(sprintf('%s "%s" has been DELETED', $table, this->getLogId())); + $this->onDelete(); + $this->loadedFromDb = false; + return true; + } + + public function __clone() + { + $this->onClone(); + $this->forgetAutoincId(); + $this->loadedFromDb = false; + $this->hasBeenModified = true; + } + + protected function onClone() + { + } + + /** + * @param array $properties + * @param DbConnection|null $connection + * + * @return static + */ + public static function create($properties = array(), DbConnection $connection = null) + { + $obj = new static(); + if ($connection !== null) { + $obj->setConnection($connection); + } + $obj->setProperties($properties); + return $obj; + } + + protected static function classWasPrefetched() + { + $class = get_called_class(); + return array_key_exists($class, self::$prefetched); + } + + /** + * @param $key + * @return static|bool + */ + protected static function getPrefetched($key) + { + $class = get_called_class(); + if (static::hasPrefetched($key)) { + if (is_string($key) + && array_key_exists($class, self::$prefetchedNames) + && array_key_exists($key, self::$prefetchedNames[$class]) + ) { + return self::$prefetched[$class][ + self::$prefetchedNames[$class][$key] + ]; + } else { + return self::$prefetched[$class][$key]; + } + } else { + return false; + } + } + + protected static function hasPrefetched($key) + { + $class = get_called_class(); + if (! array_key_exists($class, self::$prefetchStats)) { + self::$prefetchStats[$class] = (object) array( + 'miss' => 0, + 'hits' => 0, + 'hitNames' => 0, + 'combinedMiss' => 0 + ); + } + + if (is_array($key)) { + self::$prefetchStats[$class]->combinedMiss++; + return false; + } + + if (array_key_exists($class, self::$prefetched)) { + if (is_string($key) + && array_key_exists($class, self::$prefetchedNames) + && array_key_exists($key, self::$prefetchedNames[$class]) + ) { + self::$prefetchStats[$class]->hitNames++; + return true; + } elseif (array_key_exists($key, self::$prefetched[$class])) { + self::$prefetchStats[$class]->hits++; + return true; + } else { + self::$prefetchStats[$class]->miss++; + return false; + } + } else { + self::$prefetchStats[$class]->miss++; + return false; + } + } + + public static function getPrefetchStats() + { + return self::$prefetchStats; + } + + /** + * @param $id + * @param DbConnection $connection + * @return static + * @throws NotFoundError + */ + public static function loadWithAutoIncId($id, DbConnection $connection) + { + /* Need to cast to int, otherwise the id will be matched against + * object_name, which may wreak havoc if an object has a + * object_name matching some id. Note that DbObject::set() and + * DbObject::setDbProperties() will convert any property to + * string, including ids. + */ + $id = (int) $id; + + if ($prefetched = static::getPrefetched($id)) { + return $prefetched; + } + + $obj = new static; + if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) { + $table = $obj->getTableName(); + assert($connection instanceof Db); + $uuid = UuidLookup::requireUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch()); + + return self::$dbObjectStore->load($table, $uuid); + } + + $obj->setConnection($connection) + ->set($obj->autoincKeyName, $id) + ->loadFromDb(); + + return $obj; + } + + /** + * @param $id + * @param DbConnection $connection + * @return static + * @throws NotFoundError + */ + public static function load($id, DbConnection $connection) + { + if ($prefetched = static::getPrefetched($id)) { + return $prefetched; + } + /** @var DbObject $obj */ + $obj = new static; + + if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) { + $table = $obj->getTableName(); + assert($connection instanceof Db); + $uuid = UuidLookup::requireUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch()); + + return self::$dbObjectStore->load($table, $uuid); + } + + $obj->setConnection($connection)->setKey($id)->loadFromDb(); + + return $obj; + } + + /** + * @param DbConnection $connection + * @param \Zend_Db_Select $query + * @param string|null $keyColumn + * + * @return static[] + */ + public static function loadAll(DbConnection $connection, $query = null, $keyColumn = null) + { + $objects = array(); + $db = $connection->getDbAdapter(); + + if ($query === null) { + $dummy = new static; + $select = $db->select()->from($dummy->table); + } else { + $select = $query; + } + $rows = $db->fetchAll($select); + + foreach ($rows as $row) { + /** @var DbObject $obj */ + $obj = new static; + $obj->setConnection($connection)->setDbProperties($row); + if ($keyColumn === null) { + $objects[] = $obj; + } else { + $objects[$row->$keyColumn] = $obj; + } + } + + return $objects; + } + + /** + * @param DbConnection $connection + * @param bool $force + * + * @return static[] + */ + public static function prefetchAll(DbConnection $connection, $force = false) + { + $dummy = static::create(); + $class = get_class($dummy); + $autoInc = $dummy->getAutoincKeyName(); + $keyName = $dummy->getKeyName(); + + if ($force || ! array_key_exists($class, self::$prefetched)) { + self::$prefetched[$class] = static::loadAll($connection, null, $autoInc); + if (! is_array($keyName) && $keyName !== $autoInc) { + foreach (self::$prefetched[$class] as $k => $v) { + self::$prefetchedNames[$class][$v->$keyName] = $k; + } + } + } + + return self::$prefetched[$class]; + } + + public static function clearPrefetchCache() + { + $class = get_called_class(); + if (! array_key_exists($class, self::$prefetched)) { + return; + } + + unset(self::$prefetched[$class]); + unset(self::$prefetchedNames[$class]); + unset(self::$prefetchStats[$class]); + } + + public static function clearAllPrefetchCaches() + { + self::$prefetched = array(); + self::$prefetchedNames = array(); + self::$prefetchStats = array(); + } + + /** + * @param $id + * @param DbConnection $connection + * @return bool + */ + public static function exists($id, DbConnection $connection) + { + if (static::getPrefetched($id)) { + return true; + } elseif (static::classWasPrefetched()) { + return false; + } + + /** @var DbObject $obj */ + $obj = new static; + if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) { + $table = $obj->getTableName(); + assert($connection instanceof Db); + $uuid = UuidLookup::findUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch()); + if ($uuid) { + return self::$dbObjectStore->exists($table, $uuid); + } + + return false; + } + + $obj->setConnection($connection)->setKey($id); + return $obj->existsInDb(); + } + + public static function uniqueIdExists(UuidInterface $uuid, DbConnection $connection) + { + $db = $connection->getDbAdapter(); + $obj = new static; + $column = $obj->getUuidColumn(); + $query = $db->select() + ->from($obj->getTableName(), $column) + ->where("$column = ?", $connection->quoteBinary($uuid->getBytes())); + + $result = $db->fetchRow($query); + + return $result !== false; + } + + public static function requireWithUniqueId(UuidInterface $uuid, DbConnection $connection) + { + if ($object = static::loadWithUniqueId($uuid, $connection)) { + return $object; + } + + throw new NotFoundError(sprintf( + 'No %s with UUID=%s has been found', + (new static)->getTableName(), + $uuid->toString() + )); + } + + public static function loadWithUniqueId(UuidInterface $uuid, DbConnection $connection) + { + $db = $connection->getDbAdapter(); + $obj = new static; + + if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) { + $table = $obj->getTableName(); + assert($connection instanceof Db); + return self::$dbObjectStore->load($table, $uuid); + } + + $query = $db->select() + ->from($obj->getTableName()) + ->where($obj->getUuidColumn() . ' = ?', $connection->quoteBinary($uuid->getBytes())); + + $result = $db->fetchRow($query); + + if ($result) { + return $obj->setConnection($connection)->setDbProperties($result); + } + + return null; + } + + public function setUniqueId(UuidInterface $uuid) + { + if ($column = $this->getUuidColumn()) { + $binary = $uuid->getBytes(); + $current = $this->get($column); + if ($current === null) { + $this->set($column, $binary); + } else { + if ($current !== $binary) { + throw new RuntimeException(sprintf( + 'Changing the UUID (from %s to %s) is not allowed', + Uuid::fromBytes($current)->toString(), + Uuid::fromBytes($binary)->toString() + )); + } + } + } + } + + public function __destruct() + { + unset($this->db); + unset($this->connection); + } +} diff --git a/library/Director/Data/Db/DbObjectStore.php b/library/Director/Data/Db/DbObjectStore.php new file mode 100644 index 0000000..bc69b5a --- /dev/null +++ b/library/Director/Data/Db/DbObjectStore.php @@ -0,0 +1,169 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchActivity; +use Icinga\Module\Director\Db\Branch\BranchedObject; +use Icinga\Module\Director\Db\Branch\MergeErrorDeleteMissingObject; +use Icinga\Module\Director\Db\Branch\MergeErrorModificationForMissingObject; +use Icinga\Module\Director\Db\Branch\MergeErrorRecreateOnMerge; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\IcingaObject; +use Ramsey\Uuid\UuidInterface; + +/** + * Loader for Icinga/DbObjects + * + * Is aware of branches and prefetching. I would prefer to see a StoreInterface, + * with one of the above wrapping the other. But for now, this helps to clean things + * up + */ +class DbObjectStore +{ + /** @var Db */ + protected $connection; + + /** @var ?Branch */ + protected $branch; + + public function __construct(Db $connection, Branch $branch = null) + { + $this->connection = $connection; + $this->branch = $branch; + } + + /** + * @param $tableName + * @param UuidInterface $uuid + * @return DbObject|null + * @throws \Icinga\Exception\NotFoundError + */ + public function load($tableName, UuidInterface $uuid) + { + $branchedObject = BranchedObject::load($this->connection, $tableName, $uuid, $this->branch); + $object = $branchedObject->getBranchedDbObject($this->connection); + if ($object === null) { + return null; + } + + $object->setBeingLoadedFromDb(); + + return $object; + } + + /** + * @param string $tableName + * @param string $arrayIdx + * @return DbObject[]|IcingaObject[] + * @throws MergeErrorRecreateOnMerge + * @throws MergeErrorDeleteMissingObject + * @throws MergeErrorModificationForMissingObject + */ + public function loadAll($tableName, $arrayIdx = 'uuid') + { + $db = $this->connection->getDbAdapter(); + $class = DbObjectTypeRegistry::classByType($tableName); + $query = $db->select()->from($tableName)->order($arrayIdx); + $result = []; + foreach ($db->fetchAll($query) as $row) { + $row->uuid = DbUtil::binaryResult($row->uuid); + $result[$row->uuid] = $class::create((array) $row, $this->connection); + $result[$row->uuid]->setBeingLoadedFromDb(); + } + if ($this->branch && $this->branch->isBranch()) { + $query = $db->select() + ->from(BranchActivity::DB_TABLE) + ->where('branch_uuid = ?', $this->connection->quoteBinary($this->branch->getUuid()->getBytes())) + ->order('timestamp_ns ASC'); + $rows = $db->fetchAll($query); + foreach ($rows as $row) { + $activity = BranchActivity::fromDbRow($row); + if ($activity->getObjectTable() !== $tableName) { + continue; + } + $uuid = $activity->getObjectUuid(); + $binaryUuid = $uuid->getBytes(); + + $exists = isset($result[$binaryUuid]); + if ($activity->isActionCreate()) { + if ($exists) { + throw new MergeErrorRecreateOnMerge($activity); + } else { + $new = $activity->createDbObject($this->connection); + $new->setBeingLoadedFromDb(); + $result[$binaryUuid] = $new; + } + } elseif ($activity->isActionDelete()) { + if ($exists) { + unset($result[$binaryUuid]); + } else { + throw new MergeErrorDeleteMissingObject($activity); + } + } else { + if ($exists) { + $activity->applyToDbObject($result[$binaryUuid])->setBeingLoadedFromDb(); + } else { + throw new MergeErrorModificationForMissingObject($activity); + } + } + } + } + + if ($arrayIdx === 'uuid') { + return $result; + } + + $indexedResult = []; + foreach ($result as $object) { + $indexedResult[$object->get($arrayIdx)] = $object; + } + + return $indexedResult; + } + + public function exists($tableName, UuidInterface $uuid) + { + return BranchedObject::exists($this->connection, $tableName, $uuid, $this->branch->getUuid()); + } + + public function store(DbObject $object) + { + if ($this->branch && $this->branch->isBranch()) { + $activity = BranchActivity::forDbObject($object, $this->branch); + $this->connection->runFailSafeTransaction(function () use ($activity) { + $activity->store($this->connection); + BranchedObject::withActivity($activity, $this->connection)->store($this->connection); + }); + + return true; + } else { + return $object->store($this->connection); + } + } + + public function delete(DbObject $object) + { + if ($this->branch && $this->branch->isBranch()) { + $activity = BranchActivity::deleteObject($object, $this->branch); + $this->connection->runFailSafeTransaction(function () use ($activity) { + $activity->store($this->connection); + BranchedObject::load( + $this->connection, + $activity->getObjectTable(), + $activity->getObjectUuid(), + $this->branch + )->delete($this->connection); + }); + return true; + } + + return $object->delete(); + } + + public function getBranch() + { + return $this->branch; + } +} diff --git a/library/Director/Data/Db/DbObjectTypeRegistry.php b/library/Director/Data/Db/DbObjectTypeRegistry.php new file mode 100644 index 0000000..0c226d6 --- /dev/null +++ b/library/Director/Data/Db/DbObjectTypeRegistry.php @@ -0,0 +1,75 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; + +class DbObjectTypeRegistry +{ + /** + * @param $type + * @return string|DbObject Fake typehint for IDE + */ + public static function classByType($type) + { + // allow for icinga_host and host + $type = lcfirst(preg_replace('/^icinga_/', '', $type)); + + // Hint: Sync/Import are not IcingaObjects, this should be reconsidered: + if (strpos($type, 'import') === 0 || strpos($type, 'sync') === 0) { + $prefix = ''; + } elseif (strpos($type, 'data') === false) { + $prefix = 'Icinga'; + } else { + $prefix = 'Director'; + } + + // TODO: Provide a more sophisticated solution + if ($type === 'hostgroup') { + $type = 'hostGroup'; + } elseif ($type === 'usergroup') { + $type = 'userGroup'; + } elseif ($type === 'timeperiod') { + $type = 'timePeriod'; + } elseif ($type === 'servicegroup') { + $type = 'serviceGroup'; + } elseif ($type === 'service_set' || $type === 'serviceset') { + $type = 'serviceSet'; + } elseif ($type === 'apiuser') { + $type = 'apiUser'; + } elseif ($type === 'host_template_choice') { + $type = 'templateChoiceHost'; + } elseif ($type === 'service_template_choice') { + $type = 'TemplateChoiceService'; + } elseif ($type === 'scheduled_downtime' || $type === 'scheduled-downtime') { + $type = 'ScheduledDowntime'; + } + + return 'Icinga\\Module\\Director\\Objects\\' . $prefix . ucfirst($type); + } + + public static function tableNameByType($type) + { + $class = static::classByType($type); + $dummy = $class::create([]); + + return $dummy->getTableName(); + } + + public static function shortTypeForObject(DbObject $object) + { + if ($object instanceof IcingaObject) { + return $object->getShortTableName(); + } + + return $object->getTableName(); + } + + public static function newObject($type, $properties = [], Db $db = null) + { + /** @var DbObject $class fake hint for the IDE, it's a string */ + $class = self::classByType($type); + return $class::create($properties, $db); + } +} diff --git a/library/Director/Data/Db/DbObjectWithSettings.php b/library/Director/Data/Db/DbObjectWithSettings.php new file mode 100644 index 0000000..4f6b139 --- /dev/null +++ b/library/Director/Data/Db/DbObjectWithSettings.php @@ -0,0 +1,168 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Module\Director\Db; + +abstract class DbObjectWithSettings extends DbObject +{ + /** @var Db $connection */ + protected $connection; + + protected $settingsTable = 'your_table_name'; + + protected $settingsRemoteId = 'column_pointing_to_main_table_id'; + + protected $settings = []; + + public function set($key, $value) + { + if ($this->hasProperty($key)) { + return parent::set($key, $value); + } elseif ($this->hasSetterForProperty($key)) { // Hint: hasProperty checks only for Getters + return parent::set($key, $value); + } + + if (! \array_key_exists($key, $this->settings) || $value !== $this->settings[$key]) { + $this->hasBeenModified = true; + } + + $this->settings[$key] = $value; + return $this; + } + + public function get($key) + { + if ($this->hasProperty($key)) { + return parent::get($key); + } + + if (array_key_exists($key, $this->settings)) { + return $this->settings[$key]; + } + + return parent::get($key); + } + + public function setSettings($settings) + { + $settings = (array) $settings; + ksort($settings); + if ($settings !== $this->settings) { + $this->settings = $settings; + $this->hasBeenModified = true; + } + + return $this; + } + + public function getSettings() + { + // Sort them, important only for new objects + ksort($this->settings); + return $this->settings; + } + + public function getSetting($name, $default = null) + { + if (array_key_exists($name, $this->settings)) { + return $this->settings[$name]; + } + + return $default; + } + + public function getStoredSetting($name, $default = null) + { + $stored = $this->fetchSettingsFromDb(); + if (array_key_exists($name, $stored)) { + return $stored[$name]; + } + + return $default; + } + + public function __unset($key) + { + if ($this->hasProperty($key)) { + parent::__unset($key); + } + + if (array_key_exists($key, $this->settings)) { + unset($this->settings[$key]); + $this->hasBeenModified = true; + } + } + + protected function onStore() + { + $old = $this->fetchSettingsFromDb(); + $oldKeys = array_keys($old); + $newKeys = array_keys($this->settings); + $add = []; + $mod = []; + $del = []; + $id = $this->get('id'); + + foreach ($this->settings as $key => $val) { + if (array_key_exists($key, $old)) { + if ($old[$key] !== $this->settings[$key]) { + $mod[$key] = $this->settings[$key]; + } + } else { + $add[$key] = $this->settings[$key]; + } + } + + foreach (array_diff($oldKeys, $newKeys) as $key) { + $del[] = $key; + } + + $where = sprintf($this->settingsRemoteId . ' = %d AND setting_name = ?', $id); + $db = $this->getDb(); + foreach ($mod as $key => $val) { + $db->update( + $this->settingsTable, + ['setting_value' => $val], + $db->quoteInto($where, $key) + ); + } + + foreach ($add as $key => $val) { + $db->insert( + $this->settingsTable, + [ + $this->settingsRemoteId => $id, + 'setting_name' => $key, + 'setting_value' => $val + ] + ); + } + + if (! empty($del)) { + $where = sprintf($this->settingsRemoteId . ' = %d AND setting_name IN (?)', $id); + $db->delete($this->settingsTable, $db->quoteInto($where, $del)); + } + } + + protected function fetchSettingsFromDb() + { + $db = $this->getDb(); + $id = $this->get('id'); + if (! $id) { + return []; + } + + return $db->fetchPairs( + $db->select() + ->from($this->settingsTable, ['setting_name', 'setting_value']) + ->where($this->settingsRemoteId . ' = ?', $id) + ->order('setting_name') + ); + } + + protected function onLoadFromDb() + { + $this->settings = $this->fetchSettingsFromDb(); + } +} diff --git a/library/Director/Data/Db/IcingaObjectFilterRenderer.php b/library/Director/Data/Db/IcingaObjectFilterRenderer.php new file mode 100644 index 0000000..de2ec79 --- /dev/null +++ b/library/Director/Data/Db/IcingaObjectFilterRenderer.php @@ -0,0 +1,133 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterException; +use Icinga\Data\Filter\FilterExpression; + +class IcingaObjectFilterRenderer +{ + /** @var Filter */ + protected $filter; + + /** @var IcingaObjectQuery */ + protected $query; + + protected $columnMap = [ + 'host.name' => 'host.object_name', + 'service.name' => 'service.object_name', + ]; + + public function __construct(Filter $filter, IcingaObjectQuery $query) + { + $this->filter = clone($filter); + $this->fixFilterColumns($this->filter); + $this->query = $query; + } + + /** + * @param Filter $filter + * @param IcingaObjectQuery $query + * + * @return IcingaObjectQuery + */ + public static function apply(Filter $filter, IcingaObjectQuery $query) + { + $self = new static($filter, $query); + return $self->applyFilterToQuery(); + } + + /** + * @return IcingaObjectQuery + */ + protected function applyFilterToQuery() + { + $this->query->escapedWhere($this->renderFilter($this->filter)); + return $this->query; + } + + /** + * @param Filter $filter + * @return string + */ + protected function renderFilter(Filter $filter) + { + if ($filter->isChain()) { + /** @var FilterChain $filter */ + return $this->renderFilterChain($filter); + } else { + /** @var FilterExpression $filter */ + return $this->renderFilterExpression($filter); + } + } + + /** + * @param FilterChain $filter + * + * @throws FilterException + * + * @return string + */ + protected function renderFilterChain(FilterChain $filter) + { + $parts = array(); + foreach ($filter->filters() as $sub) { + $parts[] = $this->renderFilter($sub); + } + + $op = $filter->getOperatorName(); + if ($op === 'NOT') { + if (count($parts) !== 1) { + throw new FilterException( + 'NOT should have exactly one child, got %s', + count($parts) + ); + } + + return $op . ' ' . $parts[0]; + } else { + if ($filter->isRootNode()) { + return implode(' ' . $op . ' ', $parts); + } else { + return '(' . implode(' ' . $op . ' ', $parts) . ')'; + } + } + } + + protected function fixFilterColumns(Filter $filter) + { + if ($filter->isExpression()) { + /** @var FilterExpression $filter */ + $col = $filter->getColumn(); + if (array_key_exists($col, $this->columnMap)) { + $filter->setColumn($this->columnMap[$col]); + } + if (strpos($col, 'vars.') === false) { + $filter->setExpression(json_decode($filter->getExpression())); + } + } else { + /** @var FilterChain $filter */ + foreach ($filter->filters() as $sub) { + $this->fixFilterColumns($sub); + } + } + } + + /** + * @param FilterExpression $filter + * + * @return string + */ + protected function renderFilterExpression(FilterExpression $filter) + { + $query = $this->query; + $column = $query->getAliasForRequiredFilterColumn($filter->getColumn()); + return $query->whereToSql( + $column, + $filter->getSign(), + $filter->getExpression() + ); + } +} diff --git a/library/Director/Data/Db/IcingaObjectQuery.php b/library/Director/Data/Db/IcingaObjectQuery.php new file mode 100644 index 0000000..4556ba7 --- /dev/null +++ b/library/Director/Data/Db/IcingaObjectQuery.php @@ -0,0 +1,255 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Data\Db\DbQuery; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\NotImplementedError; +use Icinga\Module\Director\Db; +use Zend_Db_Expr as ZfDbExpr; +use Zend_Db_Select as ZfDbSelect; + +class IcingaObjectQuery +{ + const BASE_ALIAS = 'o'; + + /** @var Db */ + protected $connection; + + /** @var string */ + protected $type; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var ZfDbSelect */ + protected $query; + + /** @var bool */ + protected $resolved; + + /** @var array joined tables, alias => table */ + protected $requiredTables; + + /** @var array maps table aliases, alias => table*/ + protected $aliases; + + /** @var DbQuery */ + protected $dummyQuery; + + /** @var array varname => alias */ + protected $joinedVars = array(); + + protected $customVarTable; + + protected $baseQuery; + + /** + * IcingaObjectQuery constructor. + * + * @param string $type + * @param Db $connection + * @param bool $resolved + */ + public function __construct($type, Db $connection, $resolved = true) + { + $this->type = $type; + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + $this->resolved = $resolved; + $baseTable = 'icinga_' . $type; + $this->baseQuery = $this->db->select() + ->from( + array(self::BASE_ALIAS => $baseTable), + array('name' => 'object_name') + )->order(self::BASE_ALIAS . '.object_name'); + } + + public function joinVar($name) + { + if (! $this->hasJoinedVar($name)) { + $type = $this->type; + $alias = $this->safeVarAlias($name); + $varAlias = "v_$alias"; + // TODO: optionally $varRelation = sprintf('icinga_%s_resolved_var', $type); + $varRelation = sprintf('icinga_%s_var', $type); + $idCol = sprintf('%s.%s_id', $alias, $type); + + $joinOn = sprintf('%s = %s.id', $idCol, self::BASE_ALIAS); + $joinVarOn = $this->db->quoteInto( + sprintf('%s.checksum = %s.checksum AND %s.varname = ?', $alias, $varAlias, $alias), + $name + ); + + $this->baseQuery->join( + array($alias => $varRelation), + $joinOn, + array() + )->join( + array($varAlias => 'icinga_var'), + $joinVarOn, + array($alias => $varAlias . '.varvalue') + ); + + $this->joinedVars[$name] = $varAlias . '.varvalue'; + } + + return $this; + } + + // Debug only + public function getSql() + { + return (string) $this->baseQuery; + } + + public function listNames() + { + return $this->db->fetchCol( + $this->baseQuery + ); + } + + protected function hasJoinedVar($name) + { + return array_key_exists($name, $this->joinedVars); + } + + public function getJoinedVarAlias($name) + { + return $this->joinedVars[$name]; + } + + // TODO: recheck this + protected function safeVarAlias($name) + { + $alias = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $name); + $cnt = 1; + $checkAlias = $alias; + while (in_array($checkAlias, $this->joinedVars)) { + $cnt++; + $checkAlias = $alias . '_' . $cnt; + } + + return $checkAlias; + } + + public function escapedWhere($where) + { + $this->baseQuery->where(new ZfDbExpr($where)); + } + + /** + * @param $column + * @return string + * @throws NotFoundError + * @throws NotImplementedError + */ + public function getAliasForRequiredFilterColumn($column) + { + list($key, $sub) = $this->splitFilterKey($column); + if ($sub === null) { + return $key; + } else { + $objectType = $key; + } + + if ($objectType === $this->type) { + list($key, $sub) = $this->splitFilterKey($sub); + if ($sub === null) { + return $key; + } + + if ($key === 'vars') { + return $this->joinVar($sub)->getJoinedVarAlias($sub); + } else { + throw new NotFoundError('Not yet, my type: %s - %s', $objectType, $key); + } + } else { + throw new NotImplementedError('Not yet: %s - %s', $objectType, $sub); + } + } + + protected function splitFilterKey($key) + { + $dot = strpos($key, '.'); + if ($dot === false) { + return [$key, null]; + } else { + return [substr($key, 0, $dot), substr($key, $dot + 1)]; + } + } + + protected function requireTable($name) + { + if ($alias = $this->getTableAliasFromQuery($name)) { + return $alias; + } + + $this->joinTable($name); + } + + protected function joinTable($name) + { + if (!array_key_exists($name, $this->requiredTables)) { + $alias = $this->makeAlias($name); + } + + return $this->tableAliases($name); + } + + protected function hasAlias($name) + { + return array_key_exists($name, $this->aliases); + } + + protected function makeAlias($name) + { + if (substr($name, 0, 7) === 'icinga_') { + $shortName = substr($name, 7); + } else { + $shortName = $name; + } + + $parts = preg_split('/_/', $shortName, -1); + $alias = ''; + foreach ($parts as $part) { + $alias .= $part[0]; + if (! $this->hasAlias($alias)) { + return $alias; + } + } + + $cnt = 1; + do { + $cnt++; + if (! $this->hasAlias($alias . $cnt)) { + return $alias . $cnt; + } + } while (! $this->hasAlias($alias)); + + return $alias; + } + + protected function getTableAliasFromQuery($table) + { + $tables = $this->query->getPart('from'); + $key = array_search($table, $tables); + if ($key === null || $key === false) { + return false; + } + /* + 'joinType' => $type, + 'schema' => $schema, + 'tableName' => $tableName, + 'joinCondition' => $cond + */ + return $key; + } + + public function whereToSql($col, $sign, $expression) + { + return $this->connection->renderFilter(Filter::expression($col, $sign, $expression)); + } +} diff --git a/library/Director/Data/Db/ServiceSetQueryBuilder.php b/library/Director/Data/Db/ServiceSetQueryBuilder.php new file mode 100644 index 0000000..7841d1e --- /dev/null +++ b/library/Director/Data/Db/ServiceSetQueryBuilder.php @@ -0,0 +1,158 @@ +<?php + +namespace Icinga\Module\Director\Data\Db; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\BranchSupport; +use Icinga\Module\Director\Db\DbSelectParenthesis; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Web\Table\TableWithBranchSupport; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +class ServiceSetQueryBuilder +{ + use TableWithBranchSupport; + + const TABLE = BranchSupport::TABLE_ICINGA_SERVICE; + const BRANCHED_TABLE = BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE; + const SET_TABLE = BranchSupport::TABLE_ICINGA_SERVICE_SET; + const BRANCHED_SET_TABLE = BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE_SET; + + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** + * @param ?UuidInterface $uuid + */ + public function __construct(Db $connection, $uuid = null) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + if ($uuid) { + $this->setBranchUuid($uuid); + } + } + + /** + * @return \Zend_Db_Select + * @throws \Zend_Db_Select_Exception + */ + public function selectServicesForSet(IcingaServiceSet $set) + { + $db = $this->connection->getDbAdapter(); + if ($this->branchUuid) { + $right = $this->selectRightBranchedServices($set)->columns($this->getRightBranchedColumns()); + $left = $this->selectLeftBranchedServices($set)->columns($this->getLeftBranchedColumns()); + $query = $this->db->select()->from(['u' => $db->select()->union([ + 'l' => new DbSelectParenthesis($left), + 'r' => new DbSelectParenthesis($right), + ])]); + $query->order('service_set'); + } else { + $query = $this->selectServices($set)->columns($this->getColumns()); + } + + return $query; + } + + protected function selectServices(IcingaServiceSet $set) + { + return $this->db + ->select() + ->from(['o' =>self::TABLE], []) + ->joinLeft(['os' => self::SET_TABLE], 'os.id = o.service_set_id', []) + ->where('os.uuid = ?', $this->connection->quoteBinary($set->getUniqueId()->getBytes())); + } + + protected function selectLeftBranchedServices(IcingaServiceSet $set) + { + return $this + ->selectServices($set) + ->joinLeft( + ['bo' => self::BRANCHED_TABLE], + $this->db->quoteInto('bo.uuid = o.uuid AND bo.branch_uuid = ?', $this->getQuotedBranchUuid()), + [] + ); + } + + protected function selectRightBranchedServices(IcingaServiceSet $set) + { + return $this->db + ->select() + ->from(['o' => self::TABLE], []) + ->joinRight(['bo' => self::BRANCHED_TABLE], 'bo.uuid = o.uuid', []) + ->where('bo.service_set = ?', $set->get('object_name')) + ->where('bo.branch_uuid = ?', $this->getQuotedBranchUuid()); + } + + protected static function resetQueryProperties(\Zend_Db_Select $query) + { + // TODO: Keep existing UUID, becomes important when using this for other tables too (w/o UNION) + // $columns = $query->getPart($query::COLUMNS); + $query->reset($query::COLUMNS); + $query->columns('uuid'); + return $query; + } + + public function fetchServicesWithQuery(\Zend_Db_Select $query) + { + static::resetQueryProperties($query); + $db = $this->connection->getDbAdapter(); + $uuids = $db->fetchCol($query); + + $services = []; + foreach ($uuids as $uuid) { + $service = IcingaService::loadWithUniqueId(Uuid::fromBytes(DbUtil::binaryResult($uuid)), $this->connection); + $service->set('service_set', null); // TODO: CHECK THIS!!!! + + $services[$service->getObjectName()] = $service; + } + + return $services; + } + + protected function getColumns() + { + return [ + 'uuid' => 'o.uuid', // MUST be first because of UNION column order, see branchifyColumns() + 'id' => 'o.id', + 'branch_uuid' => '(null)', + 'service_set' => 'os.object_name', + 'service' => 'o.object_name', + 'disabled' => 'o.disabled', + 'object_type' => 'o.object_type', + 'blacklisted' => "('n')", + ]; + } + + protected function getLeftBranchedColumns() + { + $columns = $this->getColumns(); + $columns['branch_uuid'] = 'bo.branch_uuid'; + $columns['service_set'] = 'COALESCE(os.object_name, bo.service_set)'; + + return $this->branchifyColumns($columns); + } + + protected function getRightBranchedColumns() + { + $columns = $this->getColumns(); + $columns = $this->branchifyColumns($columns); + $columns['branch_uuid'] = 'bo.branch_uuid'; + $columns['service_set'] = 'bo.service_set'; + $columns['id'] = '(NULL)'; + + return $columns; + } + + protected function getQuotedBranchUuid() + { + return $this->connection->quoteBinary($this->branchUuid->getBytes()); + } +} diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php new file mode 100644 index 0000000..a2e3191 --- /dev/null +++ b/library/Director/Data/Exporter.php @@ -0,0 +1,303 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use gipfl\ZfDb\Adapter\Adapter; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorDatalistEntry; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Objects\IcingaTemplateChoice; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\InstantiatedViaHook; +use Icinga\Module\Director\Objects\SyncRule; +use RuntimeException; + +class Exporter +{ + /** @var Adapter|\Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var FieldReferenceLoader */ + protected $fieldReferenceLoader; + + /** @var ?HostServiceLoader */ + protected $serviceLoader = null; + + protected $exportHostServices = false; + protected $showDefaults = false; + protected $showIds = false; + protected $resolveObjects = false; + + /** @var Db */ + protected $connection; + + /** @var ?array */ + protected $chosenProperties = null; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + $this->fieldReferenceLoader = new FieldReferenceLoader($connection); + } + + public function export(DbObject $object) + { + $props = $object instanceof IcingaObject + ? $this->exportIcingaObject($object) + : $this->exportDbObject($object); + + ImportExportDeniedProperties::strip($props, $object, $this->showIds); + $this->appendTypeSpecificRelations($props, $object); + + if ($this->chosenProperties !== null) { + $chosen = []; + foreach ($this->chosenProperties as $k) { + if (array_key_exists($k, $props)) { + $chosen[$k] = $props[$k]; + } + } + + $props = $chosen; + } + + ksort($props); + return (object) $props; + } + + public function enableHostServices($enable = true) + { + $this->exportHostServices = $enable; + return $this; + } + + public function showDefaults($show = true) + { + $this->showDefaults = $show; + return $this; + } + + public function showIds($show = true) + { + $this->showIds = $show; + return $this; + } + + public function resolveObjects($resolve = true) + { + $this->resolveObjects = $resolve; + if ($this->serviceLoader) { + $this->serviceLoader->resolveObjects($resolve); + } + + return $this; + } + + public function filterProperties(array $properties) + { + $this->chosenProperties = $properties; + return $this; + } + + protected function appendTypeSpecificRelations(array &$props, DbObject $object) + { + if ($object instanceof DirectorDatalist) { + $props['entries'] = $this->exportDatalistEntries($object); + } elseif ($object instanceof DirectorDatafield) { + if (isset($props['settings']->datalist_id)) { + $props['settings']->datalist = $this->getDatalistNameForId($props['settings']->datalist_id); + unset($props['settings']->datalist_id); + } + + $props['category'] = isset($props['category_id']) + ? $this->getDatafieldCategoryNameForId($props['category_id']) + : null; + unset($props['category_id']); + } elseif ($object instanceof ImportSource) { + $props['modifiers'] = $this->exportRowModifiers($object); + } elseif ($object instanceof SyncRule) { + $props['properties'] = $this->exportSyncProperties($object); + } elseif ($object instanceof IcingaCommand) { + if (isset($props['arguments'])) { + foreach ($props['arguments'] as $key => $argument) { + if (property_exists($argument, 'command_id')) { + unset($props['arguments'][$key]->command_id); + } + } + } + } elseif ($object instanceof DirectorJob) { + if ($object->hasTimeperiod()) { + $props['timeperiod'] = $object->timeperiod()->getObjectName(); + } + unset($props['timeperiod_id']); + } elseif ($object instanceof IcingaTemplateChoice) { + if (isset($props['required_template_id'])) { + $requiredId = $props['required_template_id']; + unset($props['required_template_id']); + $props = $this->loadTemplateName($object->getObjectTableName(), $requiredId); + } + + $props['members'] = array_values($object->getMembers()); + } elseif ($object instanceof IcingaServiceSet) { + if ($object->get('host_id')) { + // Sets on Host + throw new RuntimeException('Not yet'); + } + $props['services'] = []; + foreach ($object->getServiceObjects() as $serviceObject) { + $props['services'][$serviceObject->getObjectName()] = $this->export($serviceObject); + } + ksort($props['services']); + } elseif ($object instanceof IcingaHost) { + if ($this->exportHostServices) { + $services = []; + foreach ($this->serviceLoader()->fetchServicesForHost($object) as $service) { + $services[] = $this->export($service); + } + + $props['services'] = $services; + } + } + } + + public function serviceLoader() + { + if ($this->serviceLoader === null) { + $this->serviceLoader = new HostServiceLoader($this->connection); + $this->serviceLoader->resolveObjects($this->resolveObjects); + } + + return $this->serviceLoader; + } + + protected function loadTemplateName($table, $id) + { + $db = $this->db; + $query = $db->select() + ->from(['o' => $table], 'o.object_name')->where("o.object_type = 'template'") + ->where('o.id = ?', $id); + + return $db->fetchOne($query); + } + + protected function getDatalistNameForId($id) + { + $db = $this->db; + $query = $db->select()->from('director_datalist', 'list_name')->where('id = ?', (int) $id); + return $db->fetchOne($query); + } + + protected function getDatafieldCategoryNameForId($id) + { + $db = $this->db; + $query = $db->select()->from('director_datafield_category', 'category_name')->where('id = ?', (int) $id); + return $db->fetchOne($query); + } + + protected function exportRowModifiers(ImportSource $object) + { + $modifiers = []; + // Hint: they're sorted by priority + foreach ($object->fetchRowModifiers() as $modifier) { + $modifiers[] = $this->export($modifier); + } + + return $modifiers; + } + + public function exportSyncProperties(SyncRule $object) + { + $all = []; + $db = $this->db; + $sourceNames = $db->fetchPairs( + $db->select()->from('import_source', ['id', 'source_name']) + ); + + foreach ($object->getSyncProperties() as $property) { + $properties = $property->getProperties(); + $properties['source'] = $sourceNames[$properties['source_id']]; + unset($properties['id']); + unset($properties['rule_id']); + unset($properties['source_id']); + ksort($properties); + $all[] = (object) $properties; + } + + return $all; + } + + /** + * @param DbObject $object + * @return array + */ + protected function exportDbObject(DbObject $object) + { + $props = $object->getProperties(); + if ($object instanceof DbObjectWithSettings) { + if ($object instanceof InstantiatedViaHook) { + $props['settings'] = (object) $object->getInstance()->exportSettings(); + } else { + $props['settings'] = (object) $object->getSettings(); // Already sorted + } + } + unset($props['uuid']); // Not yet + if (! $this->showDefaults) { + foreach ($props as $key => $value) { + // We assume NULL as a default value for all non-IcingaObject properties + if ($value === null) { + unset($props[$key]); + } + } + } + + return $props; + } + + /** + * @param IcingaObject $object + * @return array + * @throws \Icinga\Exception\NotFoundError + */ + protected function exportIcingaObject(IcingaObject $object) + { + $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults); + if ($object->supportsFields()) { + $props['fields'] = $this->fieldReferenceLoader->loadFor($object); + } + + return $props; + } + + protected function exportDatalistEntries(DirectorDatalist $list) + { + $entries = []; + $id = $list->get('id'); + if ($id === null) { + return $entries; + } + + $dbEntries = DirectorDatalistEntry::loadAllForList($list); + // Hint: they are loaded with entry_name key + ksort($dbEntries); + + foreach ($dbEntries as $entry) { + if ($entry->shouldBeRemoved()) { + continue; + } + $plainEntry = $entry->getProperties(); + unset($plainEntry['list_id']); + + $entries[] = $plainEntry; + } + + return $entries; + } +} diff --git a/library/Director/Data/FieldReferenceLoader.php b/library/Director/Data/FieldReferenceLoader.php new file mode 100644 index 0000000..1e3d92e --- /dev/null +++ b/library/Director/Data/FieldReferenceLoader.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use gipfl\ZfDb\Adapter\Adapter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; + +class FieldReferenceLoader +{ + /** @var Adapter|\Zend_Db_Adapter_Abstract */ + protected $db; + + public function __construct(Db $connection) + { + $this->db = $connection->getDbAdapter(); + } + + /** + * @param int $id + * @return array + */ + public function loadFor(IcingaObject $object) + { + $db = $this->db; + $id = $object->get('id'); + if ($id === null) { + return []; + } + $type = $object->getShortTableName(); + $res = $db->fetchAll( + $db->select()->from(['f' => "icinga_${type}_field"], [ + 'f.datafield_id', + 'f.is_required', + 'f.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = f.datafield_id', []) + ->where("${type}_id = ?", (int) $id) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } + + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + + return $res; + } +} diff --git a/library/Director/Data/HostServiceLoader.php b/library/Director/Data/HostServiceLoader.php new file mode 100644 index 0000000..4cc4b96 --- /dev/null +++ b/library/Director/Data/HostServiceLoader.php @@ -0,0 +1,170 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use gipfl\IcingaWeb2\Table\QueryBasedTable; +use gipfl\ZfDb\Select; +use Icinga\Data\SimpleQuery; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\AppliedServiceSetLoader; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use Icinga\Module\Director\Web\Table\IcingaHostAppliedServicesTable; +use Icinga\Module\Director\Web\Table\IcingaServiceSetServiceTable; +use Icinga\Module\Director\Web\Table\ObjectsTableService; +use Ramsey\Uuid\Uuid; +use RuntimeException; +use Zend_Db_Select; + +class HostServiceLoader +{ + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var bool */ + protected $resolveHostServices = false; + + /** @var bool */ + protected $resolveObjects = false; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function fetchServicesForHost(IcingaHost $host) + { + $table = (new ObjectsTableService($this->connection))->setHost($host); + $services = $this->fetchServicesForTable($table); + if ($this->resolveHostServices) { + foreach ($this->fetchAllServicesForHost($host) as $service) { + $services[] = $service; + } + } + + return $services; + } + + public function resolveHostServices($enable = true) + { + $this->resolveHostServices = $enable; + return $this; + } + + public function resolveObjects($resolve = true) + { + $this->resolveObjects = $resolve; + return $this; + } + + protected function fetchAllServicesForHost(IcingaHost $host) + { + $services = []; + /** @var IcingaHost[] $parents */ + $parents = IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true); + foreach ($parents as $parent) { + $table = (new ObjectsTableService($this->connection)) + ->setHost($parent) + ->setInheritedBy($host); + foreach ($this->fetchServicesForTable($table) as $service) { + $services[] = $service; + } + } + + foreach ($this->getHostServiceSetTables($host) as $table) { + foreach ($this->fetchServicesForTable($table) as $service) { + $services[] = $service; + } + } + foreach ($parents as $parent) { + foreach ($this->getHostServiceSetTables($parent, $host) as $table) { + foreach ($this->fetchServicesForTable($table) as $service) { + $services[] = $service; + } + } + } + + $appliedSets = AppliedServiceSetLoader::fetchForHost($host); + foreach ($appliedSets as $set) { + $table = IcingaServiceSetServiceTable::load($set) + // ->setHost($host) + ->setAffectedHost($host); + foreach ($this->fetchServicesForTable($table) as $service) { + $services[] = $service; + } + } + + $table = IcingaHostAppliedServicesTable::load($host); + foreach ($this->fetchServicesForTable($table) as $service) { + $services[] = $service; + } + + return $services; + } + + /** + * Duplicates Logic in HostController + * + * @param IcingaHost $host + * @param IcingaHost|null $affectedHost + * @return IcingaServiceSetServiceTable[] + */ + protected function getHostServiceSetTables(IcingaHost $host, IcingaHost $affectedHost = null) + { + $tables = []; + $db = $this->connection; + if ($affectedHost === null) { + $affectedHost = $host; + } + if ($host->get('id') === null) { + return $tables; + } + + $query = $db->getDbAdapter()->select() + ->from(['ss' => 'icinga_service_set'], 'ss.*') + ->join(['hsi' => 'icinga_service_set_inheritance'], 'hsi.parent_service_set_id = ss.id', []) + ->join(['hs' => 'icinga_service_set'], 'hs.id = hsi.service_set_id', []) + ->where('hs.host_id = ?', $host->get('id')); + + $sets = IcingaServiceSet::loadAll($db, $query, 'object_name'); + /** @var IcingaServiceSet $set*/ + foreach ($sets as $name => $set) { + $tables[] = IcingaServiceSetServiceTable::load($set) + ->setHost($host) + ->setAffectedHost($affectedHost); + } + + return $tables; + } + + protected function fetchServicesForTable(QueryBasedTable $table) + { + $query = $table->getQuery(); + if ($query instanceof Select || $query instanceof Zend_Db_Select) { + // What about SimpleQuery? IcingaHostAppliedServicesTable with branch in place? + $query->reset(Select::LIMIT_COUNT); + $query->reset(Select::LIMIT_OFFSET); + $rows = $this->db->fetchAll($query); + } elseif ($query instanceof SimpleQuery) { + $rows = $query->fetchAll(); + } else { + throw new RuntimeException('Table query needs to be either a Select or a SimpleQuery instance'); + } + $services = []; + foreach ($rows as $row) { + $service = IcingaService::loadWithUniqueId(Uuid::fromBytes($row->uuid), $this->connection); + if ($this->resolveObjects) { + $service = $service::fromPlainObject($service->toPlainObject(true), $this->connection); + } + $services[] = $service; + } + + return $services; + } +} diff --git a/library/Director/Data/ImportExportDeniedProperties.php b/library/Director/Data/ImportExportDeniedProperties.php new file mode 100644 index 0000000..747eb0f --- /dev/null +++ b/library/Director/Data/ImportExportDeniedProperties.php @@ -0,0 +1,52 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\ImportRowModifier; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\SyncRule; + +class ImportExportDeniedProperties +{ + protected static $denyProperties = [ + DirectorJob::class => [ + 'last_attempt_succeeded', + 'last_error_message', + 'ts_last_attempt', + 'ts_last_error', + ], + ImportSource::class => [ + // No state export + 'import_state', + 'last_error_message', + 'last_attempt', + ], + ImportRowModifier::class => [ + // Not state, but to be removed: + 'source_id', + ], + SyncRule::class => [ + 'sync_state', + 'last_error_message', + 'last_attempt', + ], + ]; + + public static function strip(array &$props, DbObject $object, $showIds = false) + { + // TODO: this used to exist. Double-check all imports to verify it's not in use + // $originalId = $props['id']; + + if (! $showIds) { + unset($props['id']); + } + $class = get_class($object); + if (isset(self::$denyProperties[$class])) { + foreach (self::$denyProperties[$class] as $key) { + unset($props[$key]); + } + } + } +} diff --git a/library/Director/Data/InvalidDataException.php b/library/Director/Data/InvalidDataException.php new file mode 100644 index 0000000..9abaf7c --- /dev/null +++ b/library/Director/Data/InvalidDataException.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use InvalidArgumentException; + +class InvalidDataException extends InvalidArgumentException +{ + /** + * @param string $expected + * @param mixed $value + */ + public function __construct($expected, $value) + { + parent::__construct("$expected expected, got " . static::getPhpType($value)); + } + + public static function getPhpType($var) + { + if (is_object($var)) { + return get_class($var); + } + + return gettype($var); + } +} diff --git a/library/Director/Data/Json.php b/library/Director/Data/Json.php new file mode 100644 index 0000000..78b3e67 --- /dev/null +++ b/library/Director/Data/Json.php @@ -0,0 +1,69 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use Icinga\Module\Director\Exception\JsonEncodeException; +use function json_decode; +use function json_encode; +use function json_last_error; + +class Json +{ + const DEFAULT_FLAGS = JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + /** + * Encode with well-known flags, as we require the result to be reproducible + * + * @param $mixed + * @param int|null $flags + * @return string + * @throws JsonEncodeException + */ + public static function encode($mixed, $flags = null) + { + if ($flags === null) { + $flags = self::DEFAULT_FLAGS; + } else { + $flags = self::DEFAULT_FLAGS | $flags; + } + $result = json_encode($mixed, $flags); + + if ($result === false && json_last_error() !== JSON_ERROR_NONE) { + throw JsonEncodeException::forLastJsonError(); + } + + return $result; + } + + /** + * Decode the given JSON string and make sure we get a meaningful Exception + * + * @param string $string + * @return mixed + * @throws JsonEncodeException + */ + public static function decode($string) + { + $result = json_decode($string); + + if ($result === null && json_last_error() !== JSON_ERROR_NONE) { + throw JsonEncodeException::forLastJsonError(); + } + + return $result; + } + + /** + * @param $string + * @return ?string + * @throws JsonEncodeException + */ + public static function decodeOptional($string) + { + if ($string === null) { + return null; + } + + return static::decode($string); + } +} diff --git a/library/Director/Data/PropertiesFilter.php b/library/Director/Data/PropertiesFilter.php new file mode 100644 index 0000000..a8c3906 --- /dev/null +++ b/library/Director/Data/PropertiesFilter.php @@ -0,0 +1,25 @@ +<?php + +namespace Icinga\Module\Director\Data; + +class PropertiesFilter +{ + public static $CUSTOM_PROPERTY = 'CUSTOM_PROPERTY'; + public static $HOST_PROPERTY = 'HOST_PROPERTY'; + public static $SERVICE_PROPERTY = 'SERVICE_PROPERTY'; + + protected $blacklist = array( + 'id', + 'object_name', + 'object_type', + 'disabled', + 'has_agent', + 'master_should_connect', + 'accept_config', + ); + + public function match($type, $name, $object = null) + { + return ($type != self::$HOST_PROPERTY || !in_array($name, $this->blacklist)); + } +} diff --git a/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php b/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php new file mode 100644 index 0000000..ef9f2d4 --- /dev/null +++ b/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director\Data\PropertiesFilter; + +class ArrayCustomVariablesFilter extends CustomVariablesFilter +{ + public function match($type, $name, $object = null) + { + return parent::match($type, $name, $object) + && $object !== null + && isset($object->datatype) + && ( + preg_match('/DataTypeArray[\w]*$/', $object->datatype) + || ( + preg_match('/DataTypeDatalist$/', $object->datatype) + && $object->format === 'json' + ) + ); + } +} diff --git a/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php b/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php new file mode 100644 index 0000000..91ef9cd --- /dev/null +++ b/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php @@ -0,0 +1,13 @@ +<?php + +namespace Icinga\Module\Director\Data\PropertiesFilter; + +use Icinga\Module\Director\Data\PropertiesFilter; + +class CustomVariablesFilter extends PropertiesFilter +{ + public function match($type, $name, $object = null) + { + return parent::match($type, $name, $object) && $type === self::$CUSTOM_PROPERTY; + } +} diff --git a/library/Director/Data/PropertyMangler.php b/library/Director/Data/PropertyMangler.php new file mode 100644 index 0000000..a457f1d --- /dev/null +++ b/library/Director/Data/PropertyMangler.php @@ -0,0 +1,60 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use Icinga\Module\Director\Objects\IcingaObject; +use InvalidArgumentException; + +class PropertyMangler +{ + public static function appendToArrayProperties(IcingaObject $object, $properties) + { + foreach ($properties as $key => $value) { + $current = $object->$key; + if ($current === null) { + $current = [$value]; + } elseif (is_array($current)) { + $current[] = $value; + } else { + throw new InvalidArgumentException(sprintf( + 'I can only append to arrays, %s is %s', + $key, + var_export($current, 1) + )); + } + + $object->$key = $current; + } + } + + public static function removeProperties(IcingaObject $object, $properties) + { + foreach ($properties as $key => $value) { + if ($value === true) { + $object->$key = null; + } + $current = $object->$key; + if ($current === null) { + continue; + } elseif (is_array($current)) { + $new = []; + foreach ($current as $item) { + if ($item !== $value) { + $new[] = $item; + } + } + $object->$key = $new; + } elseif (is_string($current)) { + if ($current === $value) { + $object->$key = null; + } + } else { + throw new InvalidArgumentException(sprintf( + 'I can only remove strings or from arrays, %s is %s', + $key, + var_export($current, 1) + )); + } + } + } +} diff --git a/library/Director/Data/RecursiveUtf8Validator.php b/library/Director/Data/RecursiveUtf8Validator.php new file mode 100644 index 0000000..cadfc21 --- /dev/null +++ b/library/Director/Data/RecursiveUtf8Validator.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use InvalidArgumentException; +use ipl\Html\Error; + +class RecursiveUtf8Validator +{ + protected static $rowNum; + + protected static $column; + + /** + * @param array $rows Usually array of stdClass + * @return bool + */ + public static function validateRows($rows) + { + foreach ($rows as self::$rowNum => $row) { + foreach ($row as self::$column => $value) { + static::assertUtf8($value); + } + } + + return true; + } + + protected static function assertUtf8($value) + { + if (\is_string($value)) { + static::assertUtf8String($value); + } elseif (\is_array($value) || $value instanceof \stdClass) { + foreach ((array) $value as $k => $v) { + static::assertUtf8($k); + static::assertUtf8($v); + } + } elseif ($value !== null && !\is_scalar($value)) { + throw new InvalidArgumentException("Cannot validate " . Error::getPhpTypeName($value)); + } + } + + protected static function assertUtf8String($string) + { + if (@\iconv('UTF-8', 'UTF-8', $string) != $string) { + $row = self::$rowNum; + if (is_int($row)) { + $row++; + } + throw new InvalidArgumentException(\sprintf( + 'Invalid UTF-8 on row %s, column %s: "%s" (%s)', + $row, + self::$column, + \iconv('UTF-8', 'UTF-8//IGNORE', $string), + '0x' . \bin2hex($string) + )); + } + } +} diff --git a/library/Director/Data/Serializable.php b/library/Director/Data/Serializable.php new file mode 100644 index 0000000..9f8cb63 --- /dev/null +++ b/library/Director/Data/Serializable.php @@ -0,0 +1,10 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use JsonSerializable; + +interface Serializable extends JsonSerializable +{ + public static function fromSerialization($value); +} diff --git a/library/Director/Data/SerializableValue.php b/library/Director/Data/SerializableValue.php new file mode 100644 index 0000000..5784224 --- /dev/null +++ b/library/Director/Data/SerializableValue.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Data; + +use InvalidArgumentException; +use JsonSerializable; +use stdClass; +use function get_class; +use function gettype; +use function is_array; +use function is_object; +use function is_scalar; + +class SerializableValue implements Serializable +{ + protected $value = []; + + /** + * @param stdClass|array $object + * @return static + */ + public static function fromSerialization($value) + { + $self = new static; + static::assertSerializableValue($value); + $self->value = $value; + + return $self; + } + + public static function wantSerializable($value) + { + if ($value instanceof SerializableValue) { + return $value; + } + + return static::fromSerialization($value); + } + + /** + * TODO: Check whether json_encode() is faster + * + * @param mixed $value + * @return bool + */ + protected static function assertSerializableValue($value) + { + if ($value === null || is_scalar($value)) { + return true; + } + if (is_object($value)) { + if ($value instanceof JsonSerializable) { + return true; + } + + if ($value instanceof stdClass) { + foreach ((array) $value as $val) { + static::assertSerializableValue($val); + } + + return true; + } + } + + if (is_array($value)) { + foreach ($value as $val) { + static::assertSerializableValue($val); + } + + return true; + } + + throw new InvalidArgumentException('Serializable value expected, got ' . static::getPhpType($value)); + } + + protected static function getPhpType($var) + { + if (is_object($var)) { + return get_class($var); + } + + return gettype($var); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->value; + } +} diff --git a/library/Director/Data/ValueFilter.php b/library/Director/Data/ValueFilter.php new file mode 100644 index 0000000..926214f --- /dev/null +++ b/library/Director/Data/ValueFilter.php @@ -0,0 +1,10 @@ +<?php + +// TODO: move elsewhere, this is for forms +namespace Icinga\Module\Director\Data; + +use Zend_Filter_Interface; + +interface ValueFilter extends Zend_Filter_Interface +{ +} diff --git a/library/Director/Data/ValueFilter/FilterBoolean.php b/library/Director/Data/ValueFilter/FilterBoolean.php new file mode 100644 index 0000000..1fadec3 --- /dev/null +++ b/library/Director/Data/ValueFilter/FilterBoolean.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Data\ValueFilter; + +use Icinga\Module\Director\Data\ValueFilter; + +class FilterBoolean implements ValueFilter +{ + public function filter($value) + { + if ($value === 'y' || $value === true) { + return true; + } elseif ($value === 'n' || $value === false) { + return false; + } + + return null; + } +} diff --git a/library/Director/Data/ValueFilter/FilterInt.php b/library/Director/Data/ValueFilter/FilterInt.php new file mode 100644 index 0000000..d51ce8d --- /dev/null +++ b/library/Director/Data/ValueFilter/FilterInt.php @@ -0,0 +1,21 @@ +<?php + +namespace Icinga\Module\Director\Data\ValueFilter; + +use Icinga\Module\Director\Data\ValueFilter; + +class FilterInt implements ValueFilter +{ + public function filter($value) + { + if ($value === '' || $value === null) { + return null; + } + + if (is_string($value) && ! ctype_digit($value)) { + return $value; + } + + return (int) ((string) $value); + } +} diff --git a/library/Director/DataType/DataTypeArray.php b/library/Director/DataType/DataTypeArray.php new file mode 100644 index 0000000..a7667d9 --- /dev/null +++ b/library/Director/DataType/DataTypeArray.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class DataTypeArray extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + return $form->createElement('extensibleSet', $name); + } +} diff --git a/library/Director/DataType/DataTypeBoolean.php b/library/Director/DataType/DataTypeBoolean.php new file mode 100644 index 0000000..d9edc60 --- /dev/null +++ b/library/Director/DataType/DataTypeBoolean.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\Decorator\ViewHelperRaw; +use Icinga\Module\Director\Web\Form\QuickForm; +use Zend_Form_Element as ZfElement; + +class DataTypeBoolean extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + return $this->applyRawViewHelper( + $form->createElement('boolean', $name) + ); + } + + protected function applyRawViewHelper(ZfElement $element) + { + $vhClass = 'Zend_Form_Decorator_ViewHelper'; + $decorators = $element->getDecorators(); + if (array_key_exists($vhClass, $decorators)) { + $decorators[$vhClass] = new ViewHelperRaw; + $element->setDecorators($decorators); + } + + return $element; + } +} diff --git a/library/Director/DataType/DataTypeDatalist.php b/library/Director/DataType/DataTypeDatalist.php new file mode 100644 index 0000000..354c7c3 --- /dev/null +++ b/library/Director/DataType/DataTypeDatalist.php @@ -0,0 +1,159 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Acl; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Objects\DirectorDatalistEntry; +use Icinga\Module\Director\Web\Form\DirectorForm; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\Validate\IsDataListEntry; + +class DataTypeDatalist extends DataTypeHook +{ + /** + * @param $name + * @param QuickForm $form + * @return \Zend_Form_Element + * @throws \Zend_Form_Exception + */ + public function getFormElement($name, QuickForm $form) + { + $params = []; + $behavior = $this->getSetting('behavior', 'strict'); + $targetDataType = $this->getSetting('data_type', 'string'); + $listId = $this->getSetting('datalist_id'); + + if ($behavior === 'strict') { + $enum = $this->getEntries($form); + if ($targetDataType === 'string') { + $params['sorted'] = true; + $params = ['multiOptions' => $form->optionalEnum($enum)]; + $type = 'select'; + } else { + $params = ['multiOptions' => $form->optionalEnum($enum)]; + $type = 'extensibleSet'; + } + } else { + if ($targetDataType === 'string') { + $type = 'text'; + } else { + $type = 'extensibleSet'; + } + $params['class'] = 'director-suggest'; + $params['data-suggestion-context'] = "dataListValuesForListId!$listId"; + } + $element = $form->createElement($type, $name, $params); + if ($behavior === 'suggest_strict') { + $element->addValidator(new IsDataListEntry($listId, $form->getDb())); + } + + if ($behavior === 'suggest_extend') { + $form->callOnSuccess(function (DirectorForm $form) use ($name, $listId) { + $value = (array) $form->getValue($name); + if ($value === null) { + return; + } + + $db = $form->getDb(); + foreach ($value as $entry) { + if ($entry !== '') { + $this->createEntryIfNotExists($db, $listId, $entry); + } + } + }); + } + + return $element; + } + + /** + * @param Db $db + * @param $listId + * @param $entry + */ + protected function createEntryIfNotExists(Db $db, $listId, $entry) + { + if (! DirectorDatalistEntry::exists([ + 'list_id' => $listId, + 'entry_name' => $entry, + ], $db)) { + DirectorDatalistEntry::create([ + 'list_id' => $listId, + 'entry_name' => $entry, + 'entry_value' => $entry, + ])->store($db); + } + } + + protected function getEntries(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb()->getDbAdapter(); + $roles = Acl::instance()->listRoleNames(); + $select = $db->select() + ->from('director_datalist_entry', ['entry_name', 'entry_value']) + ->where('list_id = ?', $this->getSetting('datalist_id')) + ->order('entry_value ASC'); + + if (empty($roles)) { + $select->where('allowed_roles IS NULL'); + } else { + $parts = ['allowed_roles IS NULL']; + foreach ($roles as $role) { + $parts[] = $db->quoteInto("allowed_roles LIKE ?", '%' . \json_encode($role) . '%'); + } + $select->where('(' . \implode(' OR ', $parts) . ')'); + } + + return $db->fetchPairs($select); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb(); + + $form->addElement('select', 'datalist_id', [ + 'label' => 'List name', + 'required' => true, + 'multiOptions' => $form->optionalEnum($db->enumDatalist()), + ]); + + $form->addElement('select', 'data_type', [ + 'label' => $form->translate('Target data type'), + 'multiOptions' => $form->optionalEnum([ + 'string' => $form->translate('String'), + 'array' => $form->translate('Array'), + ]), + 'description' => $form->translate( + 'Whether this should be a String or an Array in the generated' + . ' Icinga configuration. In case you opt for Array, Director' + . ' users will be able to select multiple elements from the list' + ), + 'required' => true, + ]); + + $form->addElement('select', 'behavior', [ + 'label' => $form->translate('Element behavior'), + 'value' => 'strict', + 'description' => $form->translate( + 'This allows to show either a drop-down list or an auto-completion' + ), + 'multiOptions' => [ + 'strict' => $form->translate('Dropdown (list values only)'), + $form->translate('Autocomplete') => [ + 'suggest_strict' => $form->translate('Strict, list values only'), + 'suggest_optional' => $form->translate('Allow for values not on the list'), + 'suggest_extend' => $form->translate('Extend the list with new values'), + ] + ] + ]); + } +} diff --git a/library/Director/DataType/DataTypeDictionary.php b/library/Director/DataType/DataTypeDictionary.php new file mode 100644 index 0000000..7880698 --- /dev/null +++ b/library/Director/DataType/DataTypeDictionary.php @@ -0,0 +1,107 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; +use ipl\Html\Html; +use RuntimeException; + +class DataTypeDictionary extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + if (strpos($name, 'var_') !== 0) { + throw new InvalidArgumentException( + "'$name' is not a valid candidate for a Nested Dictionary, 'var_*' expected" + ); + } + /** @var DirectorObjectForm $form */ + $object = $form->getObject(); + if ($form->isTemplate()) { + return $form->createElement('simpleNote', $name, [ + 'ignore' => true, + 'value' => Html::tag('span', $form->translate('To be managed on objects only')), + ]); + } + if (! $object->hasBeenLoadedFromDb()) { + return $form->createElement('simpleNote', $name, [ + 'ignore' => true, + 'value' => Html::tag( + 'span', + $form->translate('Can be managed once this object has been created') + ), + ]); + } + $params = [ + 'varname' => substr($name, 4), + ]; + if ($object instanceof IcingaHost) { + $params['host'] = $object->getObjectName(); + } elseif ($object instanceof IcingaService) { + $params['host'] = $object->get('host'); + $params['service'] = $object->getObjectName(); + } + return $form->createElement('InstanceSummary', $name, [ + 'linkParams' => $params + ]); + } + + public static function addSettingsFormFields(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb()->getDbAdapter(); + $enum = [ + 'host' => $form->translate('Hosts'), + 'service' => $form->translate('Services'), + ]; + + $form->addElement('select', 'template_object_type', [ + 'label' => $form->translate('Template (Object) Type'), + 'description' => $form->translate( + 'Please choose a specific Icinga object type' + ), + 'class' => 'autosubmit', + 'required' => true, + 'multiOptions' => $form->optionalEnum($enum), + 'sorted' => true, + ]); + + // There should be a helper method for this + if ($form->hasBeenSent()) { + $type = $form->getSentOrObjectValue('template_object_type'); + } else { + $type = $form->getObject()->getSetting('template_object_type'); + } + if (empty($type)) { + return $form; + } + + if (array_key_exists($type, $enum)) { + $form->addElement('select', 'template_name', [ + 'label' => $form->translate('Template'), + 'multiOptions' => $form->optionalEnum(self::fetchTemplateNames($db, $type)), + 'required' => true, + ]); + } else { + throw new RuntimeException("$type is not a valid Dictionary object type"); + } + + return $form; + } + + protected static function fetchTemplateNames($db, $type) + { + $query = $db->select() + ->from("icinga_$type", ['a' => 'object_name', 'b' => 'object_name']) + ->where('object_type = ?', 'template') + ->where('template_choice_id IS NULL') + ->order('object_name'); + + return $db->fetchPairs($query); + } +} diff --git a/library/Director/DataType/DataTypeDirectorObject.php b/library/Director/DataType/DataTypeDirectorObject.php new file mode 100644 index 0000000..7f313e0 --- /dev/null +++ b/library/Director/DataType/DataTypeDirectorObject.php @@ -0,0 +1,87 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; + +class DataTypeDirectorObject extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb()->getDbAdapter(); + + $type = $this->getSetting('icinga_object_type'); + $dummy = IcingaObject::createByType($type); + + $display = in_array($type, ['service_set', 'notification']) + ? 'object_name' + : 'COALESCE(display_name, object_name)'; + $query = $db->select()->from($dummy->getTableName(), [ + 'object_name' => 'object_name', + 'display_name' => $display + ])->order($display); + + if ($type === 'service_set') { + $query->where('host_id IS NULL'); + } elseif ($type === 'notification') { + $query->where('object_type = ?', 'apply'); + } else { + $query->where('object_type = ?', 'object'); + } + + $enum = $db->fetchPairs($query); + + $params = []; + if ($this->getSetting('data_type') === 'array') { + $elementType = $type === 'notification' ? 'select' : 'extensibleSet'; + $params['sorted'] = true; + $params = ['multiOptions' => $enum]; + } else { + $params = ['multiOptions' => [ + null => $form->translate('- please choose -'), + ] + $enum]; + $elementType = 'select'; + } + + return $form->createElement($elementType, $name, $params); + } + + public static function addSettingsFormFields(QuickForm $form) + { + $enum = [ + 'host' => $form->translate('Hosts'), + 'hostgroup' => $form->translate('Host groups'), + 'notification' => $form->translate('Notification Apply Rules'), + 'service' => $form->translate('Services'), + 'servicegroup' => $form->translate('Service groups'), + 'service_set' => $form->translate('Service Set'), + 'user' => $form->translate('Users'), + 'usergroup' => $form->translate('User groups'), + ]; + + $form->addElement('select', 'icinga_object_type', [ + 'label' => $form->translate('Object'), + 'description' => $form->translate( + 'Please choose a specific Icinga object type' + ), + 'required' => true, + 'multiOptions' => $form->optionalEnum($enum), + 'sorted' => true, + ]); + + $form->addElement('select', 'data_type', [ + 'label' => $form->translate('Target data type'), + 'multiOptions' => $form->optionalEnum([ + 'string' => $form->translate('String'), + 'array' => $form->translate('Array'), + ]), + 'required' => true, + ]); + + return $form; + } +} diff --git a/library/Director/DataType/DataTypeNumber.php b/library/Director/DataType/DataTypeNumber.php new file mode 100644 index 0000000..cd47f88 --- /dev/null +++ b/library/Director/DataType/DataTypeNumber.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Data\ValueFilter\FilterInt; + +class DataTypeNumber extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + $element = $form->createElement('text', $name) + ->addValidator('int') + ->addFilter(new FilterInt); + + return $element; + } +} diff --git a/library/Director/DataType/DataTypeSqlQuery.php b/library/Director/DataType/DataTypeSqlQuery.php new file mode 100644 index 0000000..07e7418 --- /dev/null +++ b/library/Director/DataType/DataTypeSqlQuery.php @@ -0,0 +1,96 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Exception; +use Icinga\Data\Db\DbConnection; +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Util; + +class DataTypeSqlQuery extends DataTypeHook +{ + /** @var \Zend_Db_Adapter_Pdo_Abstract */ + protected $db; + + protected static $cachedResult; + + protected static $cacheTime = 0; + + public function getFormElement($name, QuickForm $form) + { + try { + $data = $this->fetchData(); + $error = false; + } catch (Exception $e) { + $data = array(); + $error = sprintf($form->translate('Unable to fetch data: %s'), $e->getMessage()); + } + + $params = []; + if ($this->getSetting('data_type') === 'array') { + $type = 'extensibleSet'; + $params['sorted'] = true; + $params = ['multiOptions' => $data]; + } else { + $params = ['multiOptions' => [ + null => $form->translate('- please choose -'), + ] + $data]; + $type = 'select'; + } + + $element = $form->createElement($type, $name, $params); + + if ($error) { + $element->addError($error); + } + + return $element; + } + + protected function fetchData() + { + // TODO: Hash _:) + //if (self::$cachedResult === null || (time() - self::$cacheTime > 3)) { + self::$cachedResult = $this->db()->fetchPairs($this->settings['query']); + self::$cacheTime = time(); + // } + + return self::$cachedResult; + } + + public static function addSettingsFormFields(QuickForm $form) + { + Util::addDbResourceFormElement($form, 'resource'); + + $form->addElement('textarea', 'query', array( + 'label' => 'DB Query', + 'description' => 'This query should return exactly two columns, value and label', + 'required' => true, + 'rows' => 10, + )); + + $form->addElement('select', 'data_type', [ + 'label' => $form->translate('Target data type'), + 'multiOptions' => $form->optionalEnum([ + 'string' => $form->translate('String'), + 'array' => $form->translate('Array'), + ]), + 'value' => 'string', + 'required' => true, + ]); + + return $form; + } + + protected function db() + { + if ($this->db === null) { + $this->db = DbConnection::fromResourceName($this->settings['resource'])->getDbAdapter(); + // TODO: should be handled by resources: + $this->db->exec("SET NAMES 'utf8'"); + } + + return $this->db; + } +} diff --git a/library/Director/DataType/DataTypeString.php b/library/Director/DataType/DataTypeString.php new file mode 100644 index 0000000..a2dc196 --- /dev/null +++ b/library/Director/DataType/DataTypeString.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class DataTypeString extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + if ($this->getSetting('visibility', 'visible') === 'visible') { + $element = $form->createElement('text', $name); + } else { + $element = $form->createElement('storedPassword', $name); + } + + return $element; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'visibility', [ + 'label' => $form->translate('Visibility'), + 'multiOptions' => $form->optionalEnum([ + 'visible' => $form->translate('Visible'), + 'hidden' => $form->translate('Hidden'), + ]), + 'value' => 'visible', + 'required' => true, + ]); + + return $form; + } +} diff --git a/library/Director/DataType/DataTypeTime.php b/library/Director/DataType/DataTypeTime.php new file mode 100644 index 0000000..13b9635 --- /dev/null +++ b/library/Director/DataType/DataTypeTime.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Director\DataType; + +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class DataTypeTime extends DataTypeHook +{ + public function getFormElement($name, QuickForm $form) + { + $element = $form->createElement('text', $name); + + return $element; + } +} diff --git a/library/Director/Db.php b/library/Director/Db.php new file mode 100644 index 0000000..2a8d6c0 --- /dev/null +++ b/library/Director/Db.php @@ -0,0 +1,755 @@ +<?php + +namespace Icinga\Module\Director; + +use DateTime; +use DateTimeZone; +use Exception; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Data\Db\DbConnection; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\Objects\IcingaObject; +use RuntimeException; +use Zend_Db_Select; + +class Db extends DbConnection +{ + /** @var Settings */ + protected $settings; + + /** @var string */ + protected $masterZoneName; + + protected function db() + { + return $this->getDbAdapter(); + } + + /** + * @param $callable + * @return $this + * @throws Exception + */ + public function runFailSafeTransaction($callable) + { + if (! is_callable($callable)) { + throw new RuntimeException(__METHOD__ . ' needs a Callable'); + } + + $db = $this->db(); + $db->beginTransaction(); + try { + $callable(); + $db->commit(); + } catch (Exception $e) { + try { + $db->rollback(); + } catch (Exception $e) { + // Well... there is nothing we can do here. + } + throw $e; + } + + return $this; + } + + public static function fromResourceName($name) + { + $connection = new static(ResourceFactory::getResourceConfig($name)); + + if ($connection->isMysql()) { + $connection->setClientTimezoneForMysql(); + } elseif ($connection->isPgsql()) { + $connection->setClientTimezoneForPgsql(); + } + + return $connection; + } + + protected function getTimezoneOffset() + { + $tz = new DateTimeZone(date_default_timezone_get()); + $offset = $tz->getOffset(new DateTime()); + $prefix = $offset >= 0 ? '+' : '-'; + $offset = abs($offset); + + $hours = (int) floor($offset / 3600); + $minutes = (int) floor(($offset % 3600) / 60); + + return sprintf('%s%d:%02d', $prefix, $hours, $minutes); + } + + protected function setClientTimezoneForMysql() + { + $db = $this->getDbAdapter(); + $db->query($db->quoteInto('SET time_zone = ?', $this->getTimezoneOffset())); + } + + protected function setClientTimezoneForPgsql() + { + $db = $this->getDbAdapter(); + $db->query($db->quoteInto('SET TIME ZONE INTERVAL ? HOUR TO MINUTE', $this->getTimezoneOffset())); + } + + public function countActivitiesSinceLastDeployedConfig(IcingaObject $object = null) + { + $db = $this->db(); + + $query = 'SELECT COUNT(*) FROM director_activity_log WHERE id > COALESCE((' + . ' SELECT id FROM director_activity_log WHERE checksum = (' + . ' SELECT last_activity_checksum FROM director_generated_config WHERE checksum = (' + . ' SELECT config_checksum FROM director_deployment_log ORDER by id desc limit 1' + . ' )' + . ' )' + . '), 0)'; + + if ($object !== null) { + $query .= $db->quoteInto(' AND object_type = ?', $object->getTableName()); + $query .= $db->quoteInto(' AND object_name = ?', $object->getObjectName()); + } + return (int) $db->fetchOne($query); + } + + // TODO: use running config?! + public function getLastDeploymentActivityLogId() + { + $db = $this->db(); + + $query = ' SELECT COALESCE(id, 0) AS id FROM director_activity_log WHERE checksum = (' + . ' SELECT last_activity_checksum FROM director_generated_config WHERE checksum = (' + . ' SELECT config_checksum FROM director_deployment_log ORDER by id desc limit 1' + . ' )' + . ')'; + + return (int) $db->fetchOne($query); + } + + public function settings() + { + if ($this->settings === null) { + $this->settings = new Settings($this); + } + + return $this->settings; + } + + public function getMasterZoneName() + { + if ($this->masterZoneName === null) { + $this->masterZoneName = $this->detectMasterZoneName(); + } + + return $this->masterZoneName; + } + + protected function detectMasterZoneName() + { + if ($zone = $this->settings()->master_zone) { + return $zone; + } + + $db = $this->db(); + $query = $db->select() + ->from('icinga_zone', 'object_name') + ->where('parent_id IS NULL') + ->where('is_global = ?', 'n'); + + $zones = $db->fetchCol($query); + + if (count($zones) === 1) { + return $zones[0]; + } + + return 'master'; + } + + public function getDefaultGlobalZoneName() + { + return $this->settings()->default_global_zone; + } + + public function hasDeploymentEndpoint() + { + $db = $this->db(); + $query = $db->select()->from( + array('z' => 'icinga_zone'), + array('cnt' => 'COUNT(*)') + )->join( + array('e' => 'icinga_endpoint'), + 'e.zone_id = z.id', + array() + )->join( + array('au' => 'icinga_apiuser'), + 'e.apiuser_id = au.id', + array() + )->where('z.object_name = ?', $this->getMasterZoneName()); + + return $db->fetchOne($query) > 0; + } + + public function getEndpointNamesInDeploymentZone() + { + $db = $this->db(); + $query = $db->select()->from( + array('z' => 'icinga_zone'), + array('object_name' => 'e.object_name') + )->join( + array('e' => 'icinga_endpoint'), + 'e.zone_id = z.id', + array() + )->join( + array('au' => 'icinga_apiuser'), + 'e.apiuser_id = au.id', + array() + )->where('z.object_name = ?', $this->getMasterZoneName()) + ->order('e.object_name ASC'); + + return $db->fetchCol($query) ?: []; + } + + public function getDeploymentEndpointName() + { + $db = $this->db(); + $query = $db->select()->from( + array('z' => 'icinga_zone'), + array('object_name' => 'e.object_name') + )->join( + array('e' => 'icinga_endpoint'), + 'e.zone_id = z.id', + array() + )->join( + array('au' => 'icinga_apiuser'), + 'e.apiuser_id = au.id', + array() + )->where('z.object_name = ?', $this->getMasterZoneName()) + ->order('e.object_name ASC') + ->limit(1); + + $name = $db->fetchOne($query); + + if (! $name) { + throw new ConfigurationError( + 'Unable to detect your deployment endpoint. I was looking for' + . ' the first endpoint configured with an assigned API user' + . ' in the "%s" zone.', + $this->getMasterZoneName() + ); + } + + return $name; + } + + /** + * @return IcingaEndpoint + */ + public function getDeploymentEndpoint() + { + return IcingaEndpoint::load($this->getDeploymentEndpointName(), $this); + } + + public function getActivitylogNeighbors($id, $type = null, $name = null) + { + $db = $this->db(); + + $greater = $db->select()->from( + array('g' => 'director_activity_log'), + array('id' => 'MIN(g.id)') + )->where('id > ?', (int) $id); + + $smaller = $db->select()->from( + array('l' => 'director_activity_log'), + array('id' => 'MAX(l.id)') + )->where('id < ?', (int) $id); + + if ($type !== null) { + $greater->where('object_type = ?', $type); + $smaller->where('object_type = ?', $type); + } + + if ($name !== null) { + $greater->where('object_name = ?', $name); + $smaller->where('object_name = ?', $name); + } + + $query = $db->select()->from( + array('gt' => $greater), + array( + 'prev' => 'lt.id', + 'next' => 'gt.id' + ) + )->join( + array('lt' => $smaller), + '1 = 1', + array() + ); + + return $db->fetchRow($query); + } + + public function fetchActivityLogEntryById($id) + { + $sql = 'SELECT id, object_type, object_name, action_name,' + . ' old_properties, new_properties, author, change_time,' + . ' UNIX_TIMESTAMP(change_time) AS change_time_ts,' + . ' %s AS checksum, %s AS parent_checksum' + . ' FROM director_activity_log WHERE id = %d'; + + $sql = sprintf( + $sql, + $this->dbHexFunc('checksum'), + $this->dbHexFunc('parent_checksum'), + $id + ); + + return $this->db()->fetchRow($sql); + } + + public function fetchActivityLogChecksumById($id, $binary = true) + { + $sql = sprintf( + 'SELECT' . ' %s AS checksum FROM director_activity_log WHERE id = %d', + $this->dbHexFunc('checksum'), + (int) $id + ); + + $result = $this->db()->fetchOne($sql); + + if ($binary) { + return hex2bin($result); + } else { + return $result; + } + } + + public function fetchActivityLogIdByChecksum($checksum) + { + $sql = 'SELECT id FROM director_activity_log WHERE checksum = ?'; + return $this->db()->fetchOne( + $this->db()->quoteInto($sql, $this->quoteBinary($checksum)) + ); + } + + public function fetchActivityLogEntry($checksum) + { + $db = $this->db(); + + $sql = 'SELECT id, object_type, object_name, action_name,' + . ' old_properties, new_properties, author, change_time,' + . ' UNIX_TIMESTAMP(change_time) AS change_time_ts,' + . ' %s AS checksum, %s AS parent_checksum' + . ' FROM director_activity_log WHERE checksum = ?'; + + $sql = sprintf( + $sql, + $this->dbHexFunc('checksum'), + $this->dbHexFunc('parent_checksum') + ); + + return $db->fetchRow( + $db->quoteInto($sql, $this->quoteBinary(hex2bin($checksum))) + ); + } + + public function getLastActivityChecksum() + { + $select = "SELECT checksum FROM (SELECT * FROM (SELECT 1 AS pos, " + . $this->dbHexFunc('checksum') + . " AS checksum" + . " FROM director_activity_log ORDER BY id DESC LIMIT 1) a" + . " UNION SELECT 2 AS pos, '' AS checksum) u ORDER BY pos LIMIT 1"; + + return $this->db()->fetchOne($select); + } + + public function fetchImportStatistics() + { + $query = "SELECT 'imported_properties' AS stat_name, COUNT(*) AS stat_value" + . " FROM import_run i" + . " JOIN imported_rowset_row rs ON i.rowset_checksum = rs.rowset_checksum" + . " JOIN imported_row_property rp ON rp.row_checksum = rs.row_checksum" + . " UNION ALL" + . " SELECT 'imported_rows' AS stat_name, COUNT(*) AS stat_value" + . " FROM import_run i" + . " JOIN imported_rowset_row rs ON i.rowset_checksum = rs.rowset_checksum" + . " UNION ALL" + . " SELECT 'unique_rows' AS stat_name, COUNT(*) AS stat_value" + . " FROM imported_row" + . " UNION ALL" + . " SELECT 'unique_properties' AS stat_name, COUNT(*) AS stat_value" + . " FROM imported_property" + ; + return $this->db()->fetchPairs($query); + } + + public function getImportrunRowsetChecksum($id) + { + $db = $this->db(); + $query = $db->select() + ->from(array('r' => 'import_run'), $this->dbHexFunc('r.rowset_checksum')) + ->where('r.id = ?', $id); + + return $db->fetchOne($query); + } + + protected function fetchTemplateRelations($type) + { + $db = $this->db(); + $query = $db->select()->from( + array('p' => 'icinga_' . $type), + array( + 'name' => 'o.object_name', + 'parent' => 'p.object_name' + ) + )->join( + array('i' => 'icinga_' . $type . '_inheritance'), + 'p.id = i.parent_' . $type . '_id', + array() + )->join( + array('o' => 'icinga_' . $type), + 'o.id = i.' . $type . '_id', + array() + )->where("o.object_type = 'template'") + ->order('p.object_name') + ->order('o.object_name'); + + return $db->fetchAll($query); + } + + public function fetchTemplateTree($type) + { + $relations = $this->fetchTemplateRelations($type); + $children = array(); + $objects = array(); + foreach ($relations as $rel) { + foreach (array('name', 'parent') as $col) { + if (! array_key_exists($rel->$col, $objects)) { + $objects[$rel->$col] = (object) array( + 'name' => $rel->$col, + 'children' => array() + ); + } + } + } + + foreach ($relations as $rel) { + $objects[$rel->parent]->children[$rel->name] = $objects[$rel->name]; + $children[$rel->name] = $rel->parent; + } + + foreach ($children as $name => $object) { + unset($objects[$name]); + } + + return $objects; + } + + public function getLatestImportedChecksum($source) + { + $db = $this->db(); + $lastRun = $db->select()->from( + array('r' => 'import_run'), + array('last_checksum' => $this->dbHexFunc('r.rowset_checksum')) + ); + + if (is_int($source) || ctype_digit($source)) { + $lastRun->where('source_id = ?', (int) $source); + } else { + $lastRun->where('source_name = ?', $source); + } + + $lastRun->order('start_time DESC')->limit(1); + return $db->fetchOne($lastRun); + } + + public function getObjectSummary() + { + $types = array( + 'host', + 'hostgroup', + 'service', + 'servicegroup', + 'user', + 'usergroup', + 'command', + 'timeperiod', + 'scheduled_downtime', + 'notification', + 'apiuser', + 'endpoint', + 'zone', + 'dependency', + ); + + $queries = array(); + $db = $this->db(); + $cnt = "COALESCE(SUM(CASE WHEN o.object_type = '%s' THEN 1 ELSE 0 END), 0)"; + + foreach ($types as $type) { + $queries[] = $db->select()->from( + array('o' => 'icinga_' . $type), + array( + 'icinga_type' => "('" . $type . "')", + 'cnt_object' => sprintf($cnt, 'object'), + 'cnt_template' => sprintf($cnt, 'template'), + 'cnt_external' => sprintf($cnt, 'external_object'), + 'cnt_total' => 'COUNT(*)', + ) + ); + } + + $query = $this->db()->select()->union($queries, Zend_Db_Select::SQL_UNION_ALL); + + $result = array(); + + foreach ($db->fetchAll($query) as $row) { + $result[$row->icinga_type] = $row; + } + + return $result; + } + + public function enumCommands() + { + return $this->enumIcingaObjects('command'); + } + + public function enumCommandTemplates() + { + return $this->enumIcingaTemplates('command'); + } + + public function enumTimeperiods() + { + return $this->enumIcingaObjects('timeperiod'); + } + + public function enumCheckcommands() + { + $filters = array( + 'methods_execute IN (?)' => array('PluginCheck', 'IcingaCheck'), + + ); + return $this->enumIcingaObjects('command', $filters); + } + + public function enumEventcommands() + { + $filters = array( + 'methods_execute = ?' => 'PluginEvent', + + ); + return $this->enumIcingaObjects('command', $filters); + } + + public function enumNotificationCommands() + { + $filters = array( + 'methods_execute IN (?)' => array('PluginNotification'), + ); + return $this->enumIcingaObjects('command', $filters); + } + + public function getZoneName($id) + { + $objects = $this->enumZones(); + return $objects[$id]; + } + + public function getCommandName($id) + { + $objects = $this->enumCommands(); + return $objects[$id]; + } + + public function enumZones() + { + return $this->enumIcingaObjects('zone'); + } + + public function enumNonglobalZones() + { + $filters = array('is_global = ?' => 'n'); + return $this->enumIcingaObjects('zone', $filters); + } + + public function enumZoneTemplates() + { + return $this->enumIcingaTemplates('zone'); + } + + public function enumHosts() + { + return $this->enumIcingaObjects('host'); + } + + public function enumHostTemplates() + { + return $this->enumIcingaTemplates('host'); + } + + public function enumHostgroups() + { + return $this->enumIcingaObjects('hostgroup'); + } + + public function enumServices() + { + return $this->enumIcingaObjects('service'); + } + + public function enumServiceTemplates() + { + return $this->enumIcingaTemplates('service'); + } + + public function enumServicegroups() + { + return $this->enumIcingaObjects('servicegroup'); + } + + public function enumUsers() + { + return $this->enumIcingaObjects('user'); + } + + public function enumUserTemplates() + { + return $this->enumIcingaTemplates('user'); + } + + public function enumUsergroups() + { + return $this->enumIcingaObjects('usergroup'); + } + + public function enumApiUsers() + { + return $this->enumIcingaObjects('apiuser'); + } + + public function enumSyncRule() + { + return $this->enum('sync_rule', array('id', 'rule_name')); + } + + public function enumImportSource() + { + return $this->enum('import_source', array('id', 'source_name')); + } + + public function enumDatalist() + { + return $this->enum('director_datalist', array('id', 'list_name')); + } + + public function enumDatafields() + { + return $this->enum('director_datafield', array( + 'id', + "caption || ' (' || varname || ')'", + )); + } + + public function enum($table, $columns = null, $filters = array()) + { + if ($columns === null) { + $columns = array('id', 'object_name'); + } + + $select = $this->db()->select()->from($table, $columns)->order($columns[1]); + foreach ($filters as $key => $val) { + $select->where($key, $val); + } + + return $this->db()->fetchPairs($select); + } + + public function enumIcingaObjects($type, $filters = array()) + { + $filters = array( + 'object_type IN (?)' => array('object', 'external_object') + ) + $filters; + + return $this->enum('icinga_' . $type, null, $filters); + } + + public function enumIcingaTemplates($type, $filters = array()) + { + $filters = array('object_type = ?' => 'template') + $filters; + return $this->enum('icinga_' . $type, null, $filters); + } + + public function fetchDistinctHostVars() + { + $select = $this->db()->select()->distinct()->from( + array('hv' => 'icinga_host_var'), + array( + 'varname' => 'hv.varname', + 'format' => 'hv.format', + 'caption' => 'df.caption', + 'datatype' => 'df.datatype' + ) + )->joinLeft( + array('df' => 'director_datafield'), + 'df.varname = hv.varname', + array() + )->order('varname'); + + return $this->db()->fetchAll($select); + } + + public function fetchDistinctServiceVars() + { + $select = $this->db()->select()->distinct()->from( + array('sv' => 'icinga_service_var'), + array( + 'varname' => 'sv.varname', + 'format' => 'sv.format', + 'caption' => 'df.caption', + 'datatype' => 'df.datatype' + ) + )->joinLeft( + array('df' => 'director_datafield'), + 'df.varname = sv.varname', + array() + )->order('varname'); + + return $this->db()->fetchAll($select); + } + + public function dbHexFunc($column) + { + if ($this->isPgsql()) { + return sprintf("LOWER(ENCODE(%s, 'hex'))", $column); + } else { + return sprintf("LOWER(HEX(%s))", $column); + } + } + + public function enumDeployedConfigs() + { + $db = $this->db(); + + $columns = array( + 'checksum' => $this->dbHexFunc('c.checksum'), + ); + + if ($this->isPgsql()) { + $columns['caption'] = 'SUBSTRING(' . $columns['checksum'] . ' FROM 1 FOR 7)'; + } else { + $columns['caption'] = 'SUBSTRING(' . $columns['checksum'] . ', 1, 7)'; + } + + $query = $db->select()->from( + array('l' => 'director_deployment_log'), + $columns + )->joinLeft( + array('c' => 'director_generated_config'), + 'c.checksum = l.config_checksum', + array() + )->order('l.start_time DESC'); + + return $db->fetchPairs($query); + } +} diff --git a/library/Director/Db/AppliedServiceSetLoader.php b/library/Director/Db/AppliedServiceSetLoader.php new file mode 100644 index 0000000..b1e9408 --- /dev/null +++ b/library/Director/Db/AppliedServiceSetLoader.php @@ -0,0 +1,58 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Objects\HostApplyMatches; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaServiceSet; + +class AppliedServiceSetLoader +{ + protected $host; + + public function __construct(IcingaHost $host) + { + $this->host = $host; + } + + /** + * @return IcingaServiceSet[] + */ + public static function fetchForHost(IcingaHost $host) + { + $loader = new static($host); + return $loader->fetchAppliedServiceSets(); + } + + /** + * @return IcingaServiceSet[] + */ + protected function fetchAppliedServiceSets() + { + $sets = array(); + $matcher = HostApplyMatches::prepare($this->host); + foreach ($this->fetchAllServiceSets() as $set) { + $filter = Filter::fromQueryString($set->get('assign_filter')); + if ($matcher->matchesFilter($filter)) { + $sets[] = $set; + } + } + + return $sets; + } + + /** + * @return IcingaServiceSet[] + */ + protected function fetchAllServiceSets() + { + $db = $this->host->getDb(); + $query = $db + ->select() + ->from('icinga_service_set') + ->where('assign_filter IS NOT NULL'); + + return IcingaServiceSet::loadAll($this->host->getConnection(), $query); + } +} diff --git a/library/Director/Db/Branch/Branch.php b/library/Director/Db/Branch/Branch.php new file mode 100644 index 0000000..cd68ff0 --- /dev/null +++ b/library/Director/Db/Branch/Branch.php @@ -0,0 +1,216 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\BranchSupportHook; +use Icinga\Web\Hook; +use Icinga\Web\Request; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use RuntimeException; +use stdClass; + +/** + * Knows whether we're in a branch + */ +class Branch +{ + const PREFIX_SYNC_PREVIEW = '/syncpreview'; + + /** @var UuidInterface|null */ + protected $branchUuid; + + /** @var string */ + protected $name; + + /** @var string */ + protected $owner; + + /** @var @var string */ + protected $description; + + /** @var ?int */ + protected $tsMergeRequest; + + /** @var int */ + protected $cntActivities; + + public static function fromDbRow(stdClass $row) + { + $self = new static; + if (is_resource($row->uuid)) { + $row->uuid = stream_get_contents($row->uuid); + } + if (strlen($row->uuid) !== 16) { + throw new RuntimeException('Valid UUID expected, got ' . var_export($row->uuid, 1)); + } + $self->branchUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid)); + $self->name = $row->branch_name; + $self->owner = $row->owner; + $self->description = $row->description; + $self->tsMergeRequest = $row->ts_merge_request; + if (isset($row->cnt_activities)) { + $self->cntActivities = $row->cnt_activities; + } else { + $self->cntActivities = 0; + } + + return $self; + } + + /** + * @return Branch + */ + public static function detect(BranchStore $store) + { + try { + return static::forRequest(Icinga::app()->getRequest(), $store, Auth::getInstance()); + } catch (\Exception $e) { + return new static(); + } + } + + /** + * @param Request $request + * @param Db $db + * @param Auth $auth + * @return Branch + */ + public static function forRequest(Request $request, BranchStore $store, Auth $auth) + { + if ($hook = static::optionalHook()) { + return $hook->getBranchForRequest($request, $store, $auth); + } + + return new Branch; + } + + /** + * @return BranchSupportHook + */ + public static function requireHook() + { + if ($hook = static::optionalHook()) { + return $hook; + } + + throw new RuntimeException('BranchSupport Hook requested where not available'); + } + + /** + * @return BranchSupportHook|null + */ + public static function optionalHook() + { + return Hook::first('director/BranchSupport'); + } + + /** + * @param UuidInterface $uuid + * @return Branch + */ + public static function withUuid(UuidInterface $uuid) + { + $self = new static(); + $self->branchUuid = $uuid; + return $self; + } + + /** + * @return bool + */ + public function isBranch() + { + return $this->branchUuid !== null; + } + + public function assertBranch() + { + if ($this->isMain()) { + throw new RuntimeException('Branch expected, but working in main branch'); + } + } + + /** + * @return bool + */ + public function isMain() + { + return $this->branchUuid === null; + } + + /** + * @return bool + */ + public function shouldBeMerged() + { + return $this->tsMergeRequest !== null; + } + + /** + * @return bool + */ + public function isEmpty() + { + return $this->cntActivities === 0; + } + + /** + * @return int + */ + public function getActivityCount() + { + return $this->cntActivities; + } + + /** + * @return UuidInterface|null + */ + public function getUuid() + { + return $this->branchUuid; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @since v1.10.0 + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * @since v1.10.0 + * @param ?string $description + * @return void + */ + public function setDescription($description) + { + $this->description = $description; + } + + /** + * @return string + */ + public function getOwner() + { + return $this->owner; + } + + public function isSyncPreview() + { + return (bool) preg_match('/^' . preg_quote(self::PREFIX_SYNC_PREVIEW, '/') . '\//', $this->getName()); + } +} diff --git a/library/Director/Db/Branch/BranchActivity.php b/library/Director/Db/Branch/BranchActivity.php new file mode 100644 index 0000000..3812e75 --- /dev/null +++ b/library/Director/Db/Branch/BranchActivity.php @@ -0,0 +1,390 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Authentication\Auth; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Data\Json; +use Icinga\Module\Director\Data\SerializableValue; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorActivityLog; +use Icinga\Module\Director\Objects\IcingaObject; +use InvalidArgumentException; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use RuntimeException; + +class BranchActivity +{ + const DB_TABLE = 'director_branch_activity'; + + const ACTION_CREATE = DirectorActivityLog::ACTION_CREATE; + const ACTION_MODIFY = DirectorActivityLog::ACTION_MODIFY; + const ACTION_DELETE = DirectorActivityLog::ACTION_DELETE; + + /** @var int */ + protected $timestampNs; + + /** @var UuidInterface */ + protected $objectUuid; + + /** @var UuidInterface */ + protected $branchUuid; + + /** @var string create, modify, delete */ + protected $action; + + /** @var string */ + protected $objectTable; + + /** @var string */ + protected $author; + + /** @var SerializableValue */ + protected $modifiedProperties; + + /** @var ?SerializableValue */ + protected $formerProperties; + + public function __construct( + UuidInterface $objectUuid, + UuidInterface $branchUuid, + $action, + $objectType, + $author, + SerializableValue $modifiedProperties, + SerializableValue $formerProperties + ) { + $this->objectUuid = $objectUuid; + $this->branchUuid = $branchUuid; + $this->action = $action; + $this->objectTable = $objectType; + $this->author = $author; + $this->modifiedProperties = $modifiedProperties; + $this->formerProperties = $formerProperties; + } + + public static function deleteObject(DbObject $object, Branch $branch) + { + return new static( + $object->getUniqueId(), + $branch->getUuid(), + self::ACTION_DELETE, + $object->getTableName(), + Auth::getInstance()->getUser()->getUsername(), + SerializableValue::fromSerialization(null), + SerializableValue::fromSerialization(self::getFormerObjectProperties($object)) + ); + } + + public static function forDbObject(DbObject $object, Branch $branch) + { + if (! $object->hasBeenModified()) { + throw new InvalidArgumentException('Cannot get modifications for unmodified object'); + } + if (! $branch->isBranch()) { + throw new InvalidArgumentException('Branch activity requires an active branch'); + } + + $author = Auth::getInstance()->getUser()->getUsername(); + if ($object instanceof IcingaObject && $object->shouldBeRemoved()) { + $action = self::ACTION_DELETE; + $old = self::getFormerObjectProperties($object); + $new = null; + } elseif ($object->hasBeenLoadedFromDb()) { + $action = self::ACTION_MODIFY; + $old = self::getFormerObjectProperties($object); + $new = self::getObjectProperties($object); + } else { + $action = self::ACTION_CREATE; + $old = null; + $new = self::getObjectProperties($object); + } + + if ($new !== null) { + $new = PlainObjectPropertyDiff::calculate( + $old, + $new + ); + } + + return new static( + $object->getUniqueId(), + $branch->getUuid(), + $action, + $object->getTableName(), + $author, + SerializableValue::fromSerialization($new), + SerializableValue::fromSerialization($old) + ); + } + + public static function fixFakeTimestamp($timestampNs) + { + if ($timestampNs < 1600000000 * 1000000) { + // fake TS for cloned branch in sync preview + return (int) $timestampNs * 1000000; + } + + return $timestampNs; + } + + public function applyToDbObject(DbObject $object) + { + if (!$this->isActionModify()) { + throw new RuntimeException('Only BranchActivity instances with action=modify can be applied'); + } + + foreach ($this->getModifiedProperties()->jsonSerialize() as $key => $value) { + $object->set($key, $value); + } + + return $object; + } + + /** + * Hint: $connection is required, because setting groups triggered loading them. + * Should be investigated, as in theory $hostWithoutConnection->groups = 'group' + * is expected to work + * @param Db $connection + * @return DbObject|string + */ + public function createDbObject(Db $connection) + { + if (!$this->isActionCreate()) { + throw new RuntimeException('Only BranchActivity instances with action=create can create objects'); + } + + $class = DbObjectTypeRegistry::classByType($this->getObjectTable()); + $object = $class::create([], $connection); + $object->setUniqueId($this->getObjectUuid()); + foreach ($this->getModifiedProperties()->jsonSerialize() as $key => $value) { + $object->set($key, $value); + } + + return $object; + } + + public function deleteDbObject(DbObject $object) + { + if (!$this->isActionDelete()) { + throw new RuntimeException('Only BranchActivity instances with action=delete can delete objects'); + } + + return $object->delete(); + } + + public static function load($ts, Db $connection) + { + $db = $connection->getDbAdapter(); + $row = $db->fetchRow( + $db->select()->from('director_branch_activity')->where('timestamp_ns = ?', $ts) + ); + + if ($row) { + return static::fromDbRow($row); + } + + throw new NotFoundError('Not found'); + } + + protected static function fixPgResource(&$value) + { + if (is_resource($value)) { + $value = stream_get_contents($value); + } + } + + public static function fromDbRow($row) + { + static::fixPgResource($row->object_uuid); + static::fixPgResource($row->branch_uuid); + $activity = new static( + Uuid::fromBytes($row->object_uuid), + Uuid::fromBytes($row->branch_uuid), + $row->action, + $row->object_table, + $row->author, + SerializableValue::fromSerialization(Json::decodeOptional($row->modified_properties)), + SerializableValue::fromSerialization(Json::decodeOptional($row->former_properties)) + ); + $activity->timestampNs = $row->timestamp_ns; + + return $activity; + } + + /** + * Must be run in a transaction! Repeatable read? + * @param Db $connection + * @throws \Icinga\Module\Director\Exception\JsonEncodeException + * @throws \Zend_Db_Adapter_Exception + */ + public function store(Db $connection) + { + if ($this->timestampNs !== null) { + throw new InvalidArgumentException(sprintf( + 'Cannot store activity with a given timestamp: %s', + $this->timestampNs + )); + } + $db = $connection->getDbAdapter(); + $last = $db->fetchRow( + $db->select()->from('director_branch_activity', ['timestamp_ns' => 'MAX(timestamp_ns)']) + ); + if (PHP_INT_SIZE !== 8) { + throw new RuntimeException('PHP with 64bit integer support is required'); + } + $timestampNs = (int) floor(microtime(true) * 1000000); + if ($last) { + if ($last->timestamp_ns >= $timestampNs) { + $timestampNs = $last + 1; + } + } + $old = Json::encode($this->formerProperties); + $new = Json::encode($this->modifiedProperties); + + $db->insert(self::DB_TABLE, [ + 'timestamp_ns' => $timestampNs, + 'object_uuid' => $connection->quoteBinary($this->objectUuid->getBytes()), + 'branch_uuid' => $connection->quoteBinary($this->branchUuid->getBytes()), + 'action' => $this->action, + 'object_table' => $this->objectTable, + 'author' => $this->author, + 'former_properties' => $old, + 'modified_properties' => $new, + ]); + } + + /** + * @return int + */ + public function getTimestampNs() + { + return $this->timestampNs; + } + + /** + * @return int + */ + public function getTimestamp() + { + return (int) floor(BranchActivity::fixFakeTimestamp($this->timestampNs) / 1000000); + } + + /** + * @return UuidInterface + */ + public function getObjectUuid() + { + return $this->objectUuid; + } + + /** + * @return UuidInterface + */ + public function getBranchUuid() + { + return $this->branchUuid; + } + + /** + * @return string + */ + public function getObjectName() + { + return $this->getProperty('object_name', 'unknown object name'); + } + + /** + * @return string + */ + public function getAction() + { + return $this->action; + } + + public function isActionDelete() + { + return $this->action === self::ACTION_DELETE; + } + + public function isActionCreate() + { + return $this->action === self::ACTION_CREATE; + } + + public function isActionModify() + { + return $this->action === self::ACTION_MODIFY; + } + + /** + * @return string + */ + public function getObjectTable() + { + return $this->objectTable; + } + + /** + * @return string + */ + public function getAuthor() + { + return $this->author; + } + + /** + * @return ?SerializableValue + */ + public function getModifiedProperties() + { + return $this->modifiedProperties; + } + + /** + * @return ?SerializableValue + */ + public function getFormerProperties() + { + return $this->formerProperties; + } + + public function getProperty($key, $default = null) + { + if ($this->modifiedProperties) { + $properties = $this->modifiedProperties->jsonSerialize(); + if (isset($properties->$key)) { + return $properties->$key; + } + } + if ($this->formerProperties) { + $properties = $this->formerProperties->jsonSerialize(); + if (isset($properties->$key)) { + return $properties->$key; + } + } + + return $default; + } + + protected static function getFormerObjectProperties(DbObject $object) + { + if (! $object instanceof IcingaObject) { + throw new RuntimeException('Plain object helpers for DbObject must be implemented'); + } + + return (array) $object->getPlainUnmodifiedObject(); + } + + protected static function getObjectProperties(DbObject $object) + { + if (! $object instanceof IcingaObject) { + throw new RuntimeException('Plain object helpers for DbObject must be implemented'); + } + + return (array) $object->toPlainObject(false, true); + } +} diff --git a/library/Director/Db/Branch/BranchMerger.php b/library/Director/Db/Branch/BranchMerger.php new file mode 100644 index 0000000..2e84863 --- /dev/null +++ b/library/Director/Db/Branch/BranchMerger.php @@ -0,0 +1,157 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorActivityLog; +use Ramsey\Uuid\UuidInterface; + +class BranchMerger +{ + /** @var Branch */ + protected $branchUuid; + + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var array */ + protected $ignoreActivities = []; + + /** @var bool */ + protected $ignoreDeleteWhenMissing = false; + + /** @var bool */ + protected $ignoreModificationWhenMissing = false; + + /** + * Apply branch modifications + * + * TODO: allow to skip or ignore modifications, in case modified properties have + * been changed in the meantime + * + * @param UuidInterface $branchUuid + * @param Db $connection + */ + public function __construct(UuidInterface $branchUuid, Db $connection) + { + $this->branchUuid = $branchUuid; + $this->db = $connection->getDbAdapter(); + $this->connection = $connection; + } + + /** + * Skip a delete operation, when the object to be deleted does not exist + * + * @param bool $ignore + */ + public function ignoreDeleteWhenMissing($ignore = true) + { + $this->ignoreDeleteWhenMissing = $ignore; + } + + /** + * Skip a modification, when the related object does not exist + * @param bool $ignore + */ + public function ignoreModificationWhenMissing($ignore = true) + { + $this->ignoreModificationWhenMissing = $ignore; + } + + /** + * @param int $key + */ + public function ignoreActivity($key) + { + $this->ignoreActivities[$key] = true; + } + + /** + * @param BranchActivity $activity + * @return bool + */ + public function ignoresActivity(BranchActivity $activity) + { + return isset($this->ignoreActivities[$activity->getTimestampNs()]); + } + + /** + * @throws MergeError + */ + public function merge($comment = null) + { + $username = DirectorActivityLog::username(); + $this->connection->runFailSafeTransaction(function () use ($comment, $username) { + $formerActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id'); + $query = $this->db->select() + ->from(BranchActivity::DB_TABLE) + ->where('branch_uuid = ?', $this->connection->quoteBinary($this->branchUuid->getBytes())) + ->order('timestamp_ns ASC'); + $rows = $this->db->fetchAll($query); + foreach ($rows as $row) { + $activity = BranchActivity::fromDbRow($row); + $author = $activity->getAuthor(); + if ($username !== $author) { + DirectorActivityLog::overrideUsername("$author/$username"); + } + $this->applyModification($activity); + } + (new BranchStore($this->connection))->deleteByUuid($this->branchUuid); + $currentActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id'); + $firstActivityId = (int) $this->db->fetchOne( + $this->db->select()->from('director_activity_log', 'MIN(id)')->where('id > ?', $formerActivityId) + ); + if ($comment && strlen($comment)) { + $this->db->insert('director_activity_log_remark', [ + 'first_related_activity' => $firstActivityId, + 'last_related_activity' => $currentActivityId, + 'remark' => $comment, + ]); + } + }); + DirectorActivityLog::restoreUsername(); + } + + /** + * @param BranchActivity $activity + * @throws MergeError + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function applyModification(BranchActivity $activity) + { + /** @var string|DbObject $class */ + $class = DbObjectTypeRegistry::classByType($activity->getObjectTable()); + $uuid = $activity->getObjectUuid(); + + $exists = $class::uniqueIdExists($uuid, $this->connection); + if ($activity->isActionCreate()) { + if ($exists) { + if (! $this->ignoresActivity($activity)) { + throw new MergeErrorRecreateOnMerge($activity); + } + } else { + $activity->createDbObject($this->connection)->store($this->connection); + } + } elseif ($activity->isActionDelete()) { + if ($exists) { + $activity->deleteDbObject($class::requireWithUniqueId($uuid, $this->connection)); + } elseif (! $this->ignoreDeleteWhenMissing && ! $this->ignoresActivity($activity)) { + throw new MergeErrorDeleteMissingObject($activity); + } + } else { + if ($exists) { + $activity->applyToDbObject($class::requireWithUniqueId($uuid, $this->connection))->store(); + // TODO: you modified an object, and related properties have been changed in the meantime. + // We're able to detect this with the given data, and might want to offer a rebase. + } elseif (! $this->ignoreModificationWhenMissing && ! $this->ignoresActivity($activity)) { + throw new MergeErrorModificationForMissingObject($activity); + } + } + } +} diff --git a/library/Director/Db/Branch/BranchModificationInspection.php b/library/Director/Db/Branch/BranchModificationInspection.php new file mode 100644 index 0000000..978ca5d --- /dev/null +++ b/library/Director/Db/Branch/BranchModificationInspection.php @@ -0,0 +1,93 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use gipfl\Translation\StaticTranslator; +use gipfl\Translation\TranslationHelper; +use Icinga\Module\Director\Db; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use Ramsey\Uuid\UuidInterface; + +class BranchModificationInspection +{ + use TranslationHelper; + + protected $connection; + + protected $db; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function describe($table, UuidInterface $uuid) + { + return static::describeModificationStatistics($this->loadSingleTableStats($table, $uuid)); + } + + public function describeBranch(UuidInterface $uuid) + { + $tables = [ + $this->translate('API Users') => BranchSupport::BRANCHED_TABLE_ICINGA_APIUSER, + $this->translate('Endpoints') => BranchSupport::BRANCHED_TABLE_ICINGA_COMMAND, + $this->translate('Zones') => BranchSupport::BRANCHED_TABLE_ICINGA_DEPENDENCY, + $this->translate('Commands') => BranchSupport::BRANCHED_TABLE_ICINGA_ENDPOINT, + $this->translate('Hosts') => BranchSupport::BRANCHED_TABLE_ICINGA_HOST, + $this->translate('Hostgroups') => BranchSupport::BRANCHED_TABLE_ICINGA_HOSTGROUP, + $this->translate('Services') => BranchSupport::BRANCHED_TABLE_ICINGA_NOTIFICATION, + $this->translate('Servicegroups') => BranchSupport::BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME, + $this->translate('Servicesets') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE_SET, + $this->translate('Users') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE, + $this->translate('Usergroups') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICEGROUP, + $this->translate('Timeperiods') => BranchSupport::BRANCHED_TABLE_ICINGA_TIMEPERIOD, + $this->translate('Notifications') => BranchSupport::BRANCHED_TABLE_ICINGA_USER, + $this->translate('Dependencies') => BranchSupport::BRANCHED_TABLE_ICINGA_USERGROUP, + $this->translate('Scheduled Downtimes') => BranchSupport::BRANCHED_TABLE_ICINGA_ZONE, + ]; + + $parts = new HtmlDocument(); + $parts->setSeparator(Html::tag('br')); + foreach ($tables as $label => $table) { + $info = $this->describe($table, $uuid); + if (! empty($info) && $info !== '-') { + $parts->add("$label: $info"); + } + } + + return $parts; + } + + public static function describeModificationStatistics($stats) + { + $t = StaticTranslator::get(); + $relevantStats = []; + if ($stats->cnt_created > 0) { + $relevantStats[] = sprintf($t->translate('%d created'), $stats->cnt_created); + } + if ($stats->cnt_deleted > 0) { + $relevantStats[] = sprintf($t->translate('%d deleted'), $stats->cnt_deleted); + } + if ($stats->cnt_modified > 0) { + $relevantStats[] = sprintf($t->translate('%d modified'), $stats->cnt_modified); + } + if (empty($relevantStats)) { + return '-'; + } + + return implode(', ', $relevantStats); + } + + public function loadSingleTableStats($table, UuidInterface $uuid) + { + $query = $this->db->select()->from($table, [ + 'cnt_created' => "SUM(CASE WHEN branch_created = 'y' THEN 1 ELSE 0 END)", + 'cnt_deleted' => "SUM(CASE WHEN branch_deleted = 'y' THEN 1 ELSE 0 END)", + 'cnt_modified' => "SUM(CASE WHEN branch_deleted = 'n' AND branch_created = 'n' THEN 1 ELSE 0 END)", + ])->where('branch_uuid = ?', $this->connection->quoteBinary($uuid->getBytes())); + + return $this->db->fetchRow($query); + } +} diff --git a/library/Director/Db/Branch/BranchSettings.php b/library/Director/Db/Branch/BranchSettings.php new file mode 100644 index 0000000..b3fd164 --- /dev/null +++ b/library/Director/Db/Branch/BranchSettings.php @@ -0,0 +1,121 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Module\Director\Data\Json; +use function in_array; + +/** + * Hardcoded branch-related settings + */ +class BranchSettings +{ + // TODO: Ranges is weird. key = scheduled_downtime_id, range_type, range_key + const ENCODED_ARRAYS = ['imports', 'groups', 'ranges', 'users', 'usergroups']; + + const ENCODED_DICTIONARIES = ['vars', 'arguments']; + + const BRANCH_SPECIFIC_PROPERTIES = [ + 'uuid', + 'branch_uuid', + 'branch_created', + 'branch_deleted', + 'set_null', + ]; + + const BRANCH_BOOLEANS = [ + 'branch_created', + 'branch_deleted', + ]; + + const RELATED_SETS = [ + 'types', + 'states', + ]; + + public static function propertyIsEncodedArray($property) + { + return in_array($property, self::ENCODED_ARRAYS, true); + } + + public static function propertyIsRelatedSet($property) + { + // TODO: get from object class + return in_array($property, self::RELATED_SETS, true); + } + + public static function propertyIsEncodedDictionary($property) + { + return in_array($property, self::ENCODED_DICTIONARIES, true); + } + + public static function propertyIsBranchSpecific($property) + { + return in_array($property, self::BRANCH_SPECIFIC_PROPERTIES, true); + } + + public static function flattenEncodedDicationaries(array &$properties) + { + foreach (self::ENCODED_DICTIONARIES as $property) { + self::flattenProperty($properties, $property); + } + } + + public static function normalizeBranchedObjectFromDb($row) + { + $normalized = []; + $row = (array) $row; + foreach ($row as $key => $value) { + if (! static::propertyIsBranchSpecific($key)) { + if (is_resource($value)) { + $value = stream_get_contents($value); + } + if ($value !== null && static::propertyIsEncodedArray($key)) { + $value = Json::decode($value); + } + if ($value !== null && static::propertyIsRelatedSet($key)) { + // TODO: We might want to combine them (current VS branched) + $value = Json::decode($value); + } + if ($value !== null && static::propertyIsEncodedDictionary($key)) { + $value = Json::decode($value); + } + if ($value !== null) { + $normalized[$key] = $value; + } + } + } + static::flattenEncodedDicationaries($row); + if (isset($row['set_null'])) { + foreach (Json::decode($row['set_null']) as $property) { + $normalized[$property] = null; + } + } + foreach (self::BRANCH_BOOLEANS as $key) { + if ($row[$key] === 'y') { + $row[$key] = true; + } elseif ($row[$key] === 'n') { + $row[$key] = false; + } else { + throw new \RuntimeException(sprintf( + "Boolean DB property expected, got '%s' for '%s'", + $row[$key], + $key + )); + } + } + + return $normalized; + } + + public static function flattenProperty(array &$properties, $property) + { + // TODO: dots in varnames -> throw or escape? + if (isset($properties[$property])) { + foreach ((array) $properties[$property] as $key => $value) { + $properties["$property.$key"] = $value; + } + unset($properties[$property]); + } + } +} diff --git a/library/Director/Db/Branch/BranchStore.php b/library/Director/Db/Branch/BranchStore.php new file mode 100644 index 0000000..196d079 --- /dev/null +++ b/library/Director/Db/Branch/BranchStore.php @@ -0,0 +1,240 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbUtil; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +class BranchStore +{ + const TABLE = 'director_branch'; + const TABLE_ACTIVITY = 'director_branch_activity'; + + protected $connection; + + protected $db; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + /** + * @param UuidInterface $uuid + * @return ?Branch + */ + public function fetchBranchByUuid(UuidInterface $uuid) + { + return $this->newFromDbResult( + $this->select()->where('b.uuid = ?', $this->connection->quoteBinary($uuid->getBytes())) + ); + } + + /** + * @param string $name + * @return ?Branch + */ + public function fetchBranchByName($name) + { + return $this->newFromDbResult($this->select()->where('b.branch_name = ?', $name)); + } + + public function cloneBranchForSync(Branch $branch, $newName, $owner) + { + $this->runTransaction(function ($db) use ($branch, $newName, $owner) { + $tables = BranchSupport::BRANCHED_TABLES; + $tables[] = self::TABLE_ACTIVITY; + $newBranch = $this->createBranchByName($newName, $owner); + $oldQuotedUuid = DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db); + $quotedUuid = DbUtil::quoteBinaryCompat($newBranch->getUuid()->getBytes(), $db); + // $timestampNs = (int)floor(microtime(true) * 1000000); + // Hint: would love to do SELECT *, $quotedUuid AS branch_uuid FROM $table INTO $table + foreach ($tables as $table) { + $rows = $db->fetchAll($db->select()->from($table)->where('branch_uuid = ?', $oldQuotedUuid)); + foreach ($rows as $row) { + $modified = (array)$row; + $modified['branch_uuid'] = $quotedUuid; + if ($table === self::TABLE_ACTIVITY) { + $modified['timestamp_ns'] = round($modified['timestamp_ns'] / 1000000); + } + $db->insert($table, $modified); + } + } + }); + + return $this->fetchBranchByName($newName); + } + + protected function runTransaction($callback) + { + $db = $this->db; + $db->beginTransaction(); + try { + $callback($db); + $db->commit(); + } catch (\Exception $e) { + try { + $db->rollBack(); + } catch (\Exception $ignored) { + // + } + throw $e; + } + } + + public function wipeBranch(Branch $branch, $after = null) + { + $this->runTransaction(function ($db) use ($branch, $after) { + $tables = BranchSupport::BRANCHED_TABLES; + $tables[] = self::TABLE_ACTIVITY; + $quotedUuid = DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db); + $where = $db->quoteInto('branch_uuid = ?', $quotedUuid); + foreach ($tables as $table) { + if ($after && $table === self::TABLE_ACTIVITY) { + $db->delete($table, $where . ' AND timestamp_ns > ' . (int) $after); + } else { + $db->delete($table, $where); + } + } + }); + + } + + protected function newFromDbResult($query) + { + if ($row = $this->db->fetchRow($query)) { + if (is_resource($row->uuid)) { + $row->uuid = stream_get_contents($row->uuid); + } + return Branch::fromDbRow($row); + } + + return null; + } + + public function setReadyForMerge(Branch $branch) + { + $update = [ + 'ts_merge_request' => (int) floor(microtime(true) * 1000000), + 'description' => $branch->getDescription(), + ]; + + $name = $branch->getName(); + if (preg_match('#^/enforced/(.+)$#', $name, $match)) { + $update['branch_name'] = '/merge/' . substr(sha1($branch->getUuid()->getBytes()), 0, 7) . '/' . $match[1]; + } + $this->db->update('director_branch', $update, $this->db->quoteInto( + 'uuid = ?', + $this->connection->quoteBinary($branch->getUuid()->getBytes()) + )); + } + + protected function select() + { + return $this->db->select()->from(['b' => 'director_branch'], [ + 'uuid' => 'b.uuid', + 'owner' => 'b.owner', + 'branch_name' => 'b.branch_name', + 'description' => 'b.description', + 'ts_merge_request' => 'b.ts_merge_request', + 'cnt_activities' => 'COUNT(ba.timestamp_ns)', + ])->joinLeft( + ['ba' => self::TABLE_ACTIVITY], + 'b.uuid = ba.branch_uuid', + [] + )->group('b.uuid'); + } + + /** + * @param string $name + * @return Branch + * @throws \Zend_Db_Adapter_Exception + */ + public function fetchOrCreateByName($name, $owner) + { + if ($branch = $this->fetchBranchByName($name)) { + return $branch; + } + + return $this->createBranchByName($name, $owner); + } + + /** + * @param string $branchName + * @param string $owner + * @return Branch + * @throws \Zend_Db_Adapter_Exception + */ + public function createBranchByName($branchName, $owner) + { + $uuid = Uuid::uuid4(); + $properties = [ + 'uuid' => $this->connection->quoteBinary($uuid->getBytes()), + 'branch_name' => $branchName, + 'owner' => $owner, + 'description' => null, + 'ts_merge_request' => null, + ]; + $this->db->insert(self::TABLE, $properties); + + if ($branch = static::fetchBranchByUuid($uuid)) { + return $branch; + } + + throw new \RuntimeException(sprintf( + 'Branch with UUID=%s has been created, but could not be fetched from DB', + $uuid->toString() + )); + } + + public function deleteByUuid(UuidInterface $uuid) + { + return $this->db->delete(self::TABLE, $this->db->quoteInto( + 'uuid = ?', + $this->connection->quoteBinary($uuid->getBytes()) + )); + } + + /** + * @param string $name + * @return int + */ + public function deleteByName($name) + { + return $this->db->delete(self::TABLE, $this->db->quoteInto( + 'branch_name = ?', + $name + )); + } + + public function delete(Branch $branch) + { + return $this->deleteByUuid($branch->getUuid()); + } + + /** + * @param Branch $branch + * @param ?int $after + * @return float|null + */ + public function getLastActivityTime(Branch $branch, $after = null) + { + $db = $this->db; + $query = $db->select() + ->from(self::TABLE_ACTIVITY, 'MAX(timestamp_ns)') + ->where('branch_uuid = ?', DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db)); + if ($after) { + $query->where('timestamp_ns > ?', (int) $after); + } + + $last = $db->fetchOne($query); + if ($last) { + return $last / 1000000; + } + + return null; + } +} diff --git a/library/Director/Db/Branch/BranchSupport.php b/library/Director/Db/Branch/BranchSupport.php new file mode 100644 index 0000000..74be021 --- /dev/null +++ b/library/Director/Db/Branch/BranchSupport.php @@ -0,0 +1,91 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Objects\SyncRule; + +class BranchSupport +{ + const BRANCHED_TABLE_PREFIX = 'branched_'; + + const TABLE_ICINGA_APIUSER = 'icinga_apiuser'; + const TABLE_ICINGA_COMMAND = 'icinga_command'; + const TABLE_ICINGA_DEPENDENCY = 'icinga_dependency'; + const TABLE_ICINGA_ENDPOINT = 'icinga_endpoint'; + const TABLE_ICINGA_HOST = 'icinga_host'; + const TABLE_ICINGA_HOSTGROUP = 'icinga_hostgroup'; + const TABLE_ICINGA_NOTIFICATION = 'icinga_notification'; + const TABLE_ICINGA_SCHEDULED_DOWNTIME = 'icinga_scheduled_downtime'; + const TABLE_ICINGA_SERVICE = 'icinga_service'; + const TABLE_ICINGA_SERVICEGROUP = 'icinga_servicegroup'; + const TABLE_ICINGA_SERVICE_SET = 'icinga_service_set'; + const TABLE_ICINGA_TIMEPERIOD = 'icinga_timeperiod'; + const TABLE_ICINGA_USER = 'icinga_user'; + const TABLE_ICINGA_USERGROUP = 'icinga_usergroup'; + const TABLE_ICINGA_ZONE = 'icinga_zone'; + + const BRANCHED_TABLE_ICINGA_APIUSER = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_APIUSER; + const BRANCHED_TABLE_ICINGA_COMMAND = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_COMMAND; + const BRANCHED_TABLE_ICINGA_DEPENDENCY = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_DEPENDENCY; + const BRANCHED_TABLE_ICINGA_ENDPOINT = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_ENDPOINT; + const BRANCHED_TABLE_ICINGA_HOST = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_HOST; + const BRANCHED_TABLE_ICINGA_HOSTGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_HOSTGROUP; + const BRANCHED_TABLE_ICINGA_NOTIFICATION = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_NOTIFICATION; + const BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SCHEDULED_DOWNTIME; + const BRANCHED_TABLE_ICINGA_SERVICE = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICE; + const BRANCHED_TABLE_ICINGA_SERVICEGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICEGROUP; + const BRANCHED_TABLE_ICINGA_SERVICE_SET = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICE_SET; + const BRANCHED_TABLE_ICINGA_TIMEPERIOD = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_TIMEPERIOD; + const BRANCHED_TABLE_ICINGA_USER = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_USER; + const BRANCHED_TABLE_ICINGA_USERGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_USERGROUP; + const BRANCHED_TABLE_ICINGA_ZONE = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_ZONE; + + const OBJECT_TABLES = [ + self::TABLE_ICINGA_APIUSER, + self::TABLE_ICINGA_COMMAND, + self::TABLE_ICINGA_DEPENDENCY, + self::TABLE_ICINGA_ENDPOINT, + self::TABLE_ICINGA_HOST, + self::TABLE_ICINGA_HOSTGROUP, + self::TABLE_ICINGA_NOTIFICATION, + self::TABLE_ICINGA_SCHEDULED_DOWNTIME, + self::TABLE_ICINGA_SERVICE, + self::TABLE_ICINGA_SERVICEGROUP, + self::TABLE_ICINGA_SERVICE_SET, + self::TABLE_ICINGA_TIMEPERIOD, + self::TABLE_ICINGA_USER, + self::TABLE_ICINGA_USERGROUP, + self::TABLE_ICINGA_ZONE, + ]; + + const BRANCHED_TABLES = [ + self::BRANCHED_TABLE_ICINGA_APIUSER, + self::BRANCHED_TABLE_ICINGA_COMMAND, + self::BRANCHED_TABLE_ICINGA_DEPENDENCY, + self::BRANCHED_TABLE_ICINGA_ENDPOINT, + self::BRANCHED_TABLE_ICINGA_HOST, + self::BRANCHED_TABLE_ICINGA_HOSTGROUP, + self::BRANCHED_TABLE_ICINGA_NOTIFICATION, + self::BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME, + self::BRANCHED_TABLE_ICINGA_SERVICE, + self::BRANCHED_TABLE_ICINGA_SERVICEGROUP, + self::BRANCHED_TABLE_ICINGA_SERVICE_SET, + self::BRANCHED_TABLE_ICINGA_TIMEPERIOD, + self::BRANCHED_TABLE_ICINGA_USER, + self::BRANCHED_TABLE_ICINGA_USERGROUP, + self::BRANCHED_TABLE_ICINGA_ZONE, + ]; + + public static function existsForTableName($table) + { + return in_array($table, self::OBJECT_TABLES, true); + } + + public static function existsForSyncRule(SyncRule $rule) + { + return static::existsForTableName( + DbObjectTypeRegistry::tableNameByType($rule->get('object_type')) + ); + } +} diff --git a/library/Director/Db/Branch/BranchedObject.php b/library/Director/Db/Branch/BranchedObject.php new file mode 100644 index 0000000..0f276c2 --- /dev/null +++ b/library/Director/Db/Branch/BranchedObject.php @@ -0,0 +1,404 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Data\Json; +use Icinga\Module\Director\Db; +use Ramsey\Uuid\UuidInterface; +use stdClass; + +class BranchedObject +{ + /** @var UuidInterface */ + protected $branchUuid; + + /** @var ?DbObject */ + protected $object; + + /** @var ?stdClass */ + protected $changes; + + /** @var bool */ + protected $branchDeleted; + + /** @var bool */ + protected $branchCreated; + + /** @var UuidInterface */ + private $objectUuid; + + /** @var string */ + private $objectTable; + + /** @var bool */ + private $loadedAsBranchedObject = false; + + /** + * @param BranchActivity $activity + * @param Db $connection + * @return static + */ + public static function withActivity(BranchActivity $activity, Db $connection) + { + return self::loadOptional( + $connection, + $activity->getObjectTable(), + $activity->getObjectUuid(), + $activity->getBranchUuid() + )->applyActivity($activity, $connection); + } + + public function store(Db $connection) + { + if ($this->object && ! $this->object->hasBeenModified() && empty($this->changes)) { + return false; + } + $db = $connection->getDbAdapter(); + + $properties = [ + 'branch_deleted' => $this->branchDeleted ? 'y' : 'n', + 'branch_created' => $this->branchCreated ? 'y' : 'n', + ] + $this->prepareChangedProperties(); + + $table = 'branched_' . $this->objectTable; + if ($this->loadedAsBranchedObject) { + return $db->update( + $table, + $properties, + $this->prepareWhereString($connection) + ) === 1; + } else { + try { + return $db->insert($table, $this->prepareKeyProperties($connection) + $properties) === 1; + } catch (\Exception $e) { + var_dump($e->getMessage()); + var_dump($this->prepareKeyProperties($connection) + $properties); + exit; + } + } + } + + public function delete(Db $connection) + { + $db = $connection->getDbAdapter(); + $table = 'branched_' . $this->objectTable; + $branchCreated = $db->fetchOne($this->filterQuery($db->select()->from($table, 'branch_created'), $connection)); + // We do not want to nullify all properties, therefore: delete & insert + $db->delete($table, $this->prepareWhereString($connection)); + + if (! $branchCreated) { + // No need to insert a deleted object in case this object lived in this branch only + return $db->insert($table, $this->prepareKeyProperties($connection) + [ + 'branch_deleted' => 'y', + 'branch_created' => 'n', + ]) === 1; + } + + return true; + } + + protected function prepareKeyProperties(Db $connection) + { + return [ + 'uuid' => $connection->quoteBinary($this->objectUuid->getBytes()), + 'branch_uuid' => $connection->quoteBinary($this->branchUuid->getBytes()), + ]; + } + + protected function prepareWhereString(Db $connection) + { + $db = $connection->getDbAdapter(); + $objectUuid = $connection->quoteBinary($this->objectUuid->getBytes()); + $branchUuid = $connection->quoteBinary($this->branchUuid->getBytes()); + + return $db->quoteInto('uuid = ?', $objectUuid) . $db->quoteInto(' AND branch_uuid = ?', $branchUuid); + } + + /** + * @param \Zend_Db_Select $query + * @param Db $connection + * @return \Zend_Db_Select + */ + protected function filterQuery(\Zend_Db_Select $query, Db $connection) + { + return $query->where('uuid = ?', $connection->quoteBinary($this->objectUuid->getBytes())) + ->where('branch_uuid = ?', $connection->quoteBinary($this->branchUuid->getBytes())); + } + + protected function prepareChangedProperties() + { + $properties = (array) $this->changes; + + foreach (BranchSettings::ENCODED_DICTIONARIES as $property) { + $this->combineFlatDictionaries($properties, $property); + } + foreach (BranchSettings::ENCODED_DICTIONARIES as $property) { + if (isset($properties[$property])) { + $properties[$property] = Json::encode($properties[$property]); + } + } + foreach (BranchSettings::ENCODED_ARRAYS as $property) { + if (isset($properties[$property])) { + $properties[$property] = Json::encode($properties[$property]); + } + } + foreach (BranchSettings::RELATED_SETS as $property) { + if (isset($properties[$property])) { + $properties[$property] = Json::encode($properties[$property]); + } + } + $setNull = []; + if (array_key_exists('disabled', $properties) && $properties['disabled'] === null) { + unset($properties['disabled']); + } + foreach ($properties as $key => $value) { + if ($value === null) { + $setNull[] = $key; + } + } + if (empty($setNull)) { + $properties['set_null'] = null; + } else { + $properties['set_null'] = Json::encode($setNull); + } + + return $properties; + } + + protected function combineFlatDictionaries(&$properties, $prefix) + { + $vars = []; + $length = strlen($prefix) + 1; + foreach ($properties as $key => $value) { + if (substr($key, 0, $length) === "$prefix.") { + $vars[substr($key, $length)] = $value; + } + } + if (! empty($vars)) { + foreach (array_keys($vars) as $key) { + unset($properties["$prefix.$key"]); + } + $properties[$prefix] = (object) $vars; + } + } + + public function applyActivity(BranchActivity $activity, Db $connection) + { + if ($activity->isActionDelete()) { + throw new \RuntimeException('Cannot apply a delete action'); + } + if ($activity->isActionCreate()) { + if ($this->hasBeenTouchedByBranch()) { + throw new \RuntimeException('Cannot apply a CREATE activity to an already branched object'); + } else { + $this->branchCreated = true; + } + } + + foreach ($activity->getModifiedProperties()->jsonSerialize() as $key => $value) { + $this->changes[$key] = $value; + } + + return $this; + } + + /** + * @param Db $connection + * @param string $objectTable + * @param UuidInterface $uuid + * @param Branch $branch + * @return static + * @throws NotFoundError + */ + public static function load(Db $connection, $objectTable, UuidInterface $uuid, Branch $branch) + { + $object = static::loadOptional($connection, $objectTable, $uuid, $branch->getUuid()); + if ($object->getOriginalDbObject() === null && ! $object->hasBeenTouchedByBranch()) { + throw new NotFoundError('Not found'); + } + + return $object; + } + + /** + * @return bool + */ + public function hasBeenTouchedByBranch() + { + return $this->loadedAsBranchedObject; + } + + /** + * @return bool + */ + public function hasBeenDeletedByBranch() + { + return $this->branchDeleted; + } + + /** + * @return bool + */ + public function hasBeenCreatedByBranch() + { + return $this->branchCreated; + } + + /** + * @return ?DbObject + */ + public function getOriginalDbObject() + { + return $this->object; + } + + /** + * @return ?DbObject + */ + public function getBranchedDbObject(Db $connection) + { + if ($this->object) { + $branched = DbObjectTypeRegistry::newObject($this->objectTable, [], $connection); + // object_type first, to avoid: + // I can only assign for applied objects or objects with native support for assignments + if ($this->object->hasProperty('object_type')) { + $branched->set('object_type', $this->object->get('object_type')); + } + $branched->set('id', $this->object->get('id')); + $branched->set('uuid', $this->object->get('uuid')); + foreach ((array) $this->object->toPlainObject(false, true) as $key => $value) { + if ($key === 'object_type') { + continue; + } + $branched->set($key, $value); + } + } else { + $branched = DbObjectTypeRegistry::newObject($this->objectTable, [], $connection); + $branched->setUniqueId($this->objectUuid); + } + if ($this->changes === null) { + return $branched; + } + foreach ($this->changes as $key => $value) { + if ($key === 'set_null') { + if ($value !== null) { + foreach ($value as $k) { + $branched->set($k, null); + } + } + } else { + $branched->set($key, $value); + } + } + + return $branched; + } + + /** + * @return UuidInterface + */ + public function getBranchUuid() + { + return $this->branchUuid; + } + + /** + * @param Db $connection + * @param string $table + * @param UuidInterface $uuid + * @param ?UuidInterface $branchUuid + * @return static + */ + protected static function loadOptional( + Db $connection, + $table, + UuidInterface $uuid, + UuidInterface $branchUuid = null + ) { + $class = DbObjectTypeRegistry::classByType($table); + if ($row = static::optionalTableRowByUuid($connection, $table, $uuid)) { + $object = $class::fromDbRow((array) $row, $connection); + } else { + $object = null; + } + + $self = new static(); + $self->object = $object; + $self->objectUuid = $uuid; + $self->branchUuid = $branchUuid; + $self->objectTable = $table; + + if ($branchUuid && $row = static::optionalBranchedTableRowByUuid($connection, $table, $uuid, $branchUuid)) { + if ($row->branch_deleted === 'y') { + $self->branchDeleted = true; + } elseif ($row->branch_created === 'y') { + $self->branchCreated = true; + } + $self->changes = BranchSettings::normalizeBranchedObjectFromDb($row); + $self->loadedAsBranchedObject = true; + } + + return $self; + } + + public static function exists( + Db $connection, + $table, + UuidInterface $uuid, + UuidInterface $branchUuid = null + ) { + if (static::optionalTableRowByUuid($connection, $table, $uuid)) { + return true; + } + + if ($branchUuid && static::optionalBranchedTableRowByUuid($connection, $table, $uuid, $branchUuid)) { + return true; + } + + return false; + } + + /** + * @param Db $connection + * @param string $table + * @param UuidInterface $uuid + * @return stdClass|boolean + */ + protected static function optionalTableRowByUuid(Db $connection, $table, UuidInterface $uuid) + { + $db = $connection->getDbAdapter(); + + return $db->fetchRow( + $db->select()->from($table)->where('uuid = ?', $connection->quoteBinary($uuid->getBytes())) + ); + } + + /** + * @param Db $connection + * @param string $table + * @param UuidInterface $uuid + * @return stdClass|boolean + */ + protected static function optionalBranchedTableRowByUuid( + Db $connection, + $table, + UuidInterface $uuid, + UuidInterface $branchUuid + ) { + $db = $connection->getDbAdapter(); + + $query = $db->select() + ->from("branched_$table") + ->where('uuid = ?', $connection->quoteBinary($uuid->getBytes())) + ->where('branch_uuid = ?', $connection->quoteBinary($branchUuid->getBytes())); + + return $db->fetchRow($query); + } + + protected function __construct() + { + } +} diff --git a/library/Director/Db/Branch/MergeError.php b/library/Director/Db/Branch/MergeError.php new file mode 100644 index 0000000..45c7b5e --- /dev/null +++ b/library/Director/Db/Branch/MergeError.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Exception; +use gipfl\Translation\TranslationHelper; + +abstract class MergeError extends Exception +{ + use TranslationHelper; + + /** @var BranchActivity */ + protected $activity; + + public function __construct(BranchActivity $activity) + { + $this->activity = $activity; + parent::__construct($this->prepareMessage()); + } + + abstract protected function prepareMessage(); + + public function getObjectTypeName() + { + return preg_replace('/^icinga_/', '', $this->getActivity()->getObjectTable()); + } + + public function getNiceObjectName() + { + return $this->activity->getObjectName(); + } + + public function getActivity() + { + return $this->activity; + } +} diff --git a/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php new file mode 100644 index 0000000..71f89d1 --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php @@ -0,0 +1,15 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +class MergeErrorDeleteMissingObject extends MergeError +{ + public function prepareMessage() + { + return sprintf( + $this->translate('Cannot delete %s %s, it does not exist'), + $this->getObjectTypeName(), + $this->getNiceObjectName() + ); + } +} diff --git a/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php b/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php new file mode 100644 index 0000000..fa4e724 --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php @@ -0,0 +1,15 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +class MergeErrorModificationForMissingObject extends MergeError +{ + public function prepareMessage() + { + return sprintf( + $this->translate('Cannot apply modification for %s %s, object does not exist'), + $this->getObjectTypeName(), + $this->getNiceObjectName() + ); + } +} diff --git a/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php b/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php new file mode 100644 index 0000000..0bb8c40 --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php @@ -0,0 +1,15 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +class MergeErrorRecreateOnMerge extends MergeError +{ + public function prepareMessage() + { + return sprintf( + $this->translate('Cannot recreate %s %s'), + $this->getObjectTypeName(), + $this->getNiceObjectName() + ); + } +} diff --git a/library/Director/Db/Branch/PlainObjectPropertyDiff.php b/library/Director/Db/Branch/PlainObjectPropertyDiff.php new file mode 100644 index 0000000..0256798 --- /dev/null +++ b/library/Director/Db/Branch/PlainObjectPropertyDiff.php @@ -0,0 +1,50 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +class PlainObjectPropertyDiff +{ + public static function calculate(array $old = null, array $new = null) + { + if ($new === null) { + throw new \RuntimeException('Cannot diff for delete'); + } + if ($old === null) { + foreach (BranchSettings::ENCODED_DICTIONARIES as $property) { + self::flattenProperty($new, $property); + } + + return $new; + } + $unchangedKeys = []; + foreach (BranchSettings::ENCODED_DICTIONARIES as $property) { + self::flattenProperty($old, $property); + self::flattenProperty($new, $property); + } + foreach ($old as $key => $value) { + if (array_key_exists($key, $new)) { + if ($value === $new[$key]) { + $unchangedKeys[] = $key; + } + } else { + $new[$key] = null; + } + } + foreach ($unchangedKeys as $key) { + unset($new[$key]); + } + + return $new; + } + + protected static function flattenProperty(array &$properties, $property) + { + // TODO: dots in varnames -> throw or escape? + if (isset($properties[$property])) { + foreach ((array) $properties[$property] as $key => $value) { + $properties["$property.$key"] = $value; + } + unset($properties[$property]); + } + } +} diff --git a/library/Director/Db/Branch/UuidLookup.php b/library/Director/Db/Branch/UuidLookup.php new file mode 100644 index 0000000..b340e07 --- /dev/null +++ b/library/Director/Db/Branch/UuidLookup.php @@ -0,0 +1,141 @@ +<?php + +namespace Icinga\Module\Director\Db\Branch; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use function is_int; +use function is_resource; +use function is_string; + +class UuidLookup +{ + /** + * @param Db $connection + * @param Branch $branch + * @param string $objectType + * @param int|string $key + * @param IcingaHost|null $host + * @param IcingaServiceSet $set + * @return ?UuidInterface + */ + public static function findServiceUuid( + Db $connection, + Branch $branch, + $objectType = null, + $key = null, + IcingaHost $host = null, + IcingaServiceSet $set = null + ) { + $db = $connection->getDbAdapter(); + $query = $db->select()->from('icinga_service', 'uuid'); + if ($objectType) { + $query->where('object_type = ?', $objectType); + } + $query = self::addKeyToQuery($connection, $query, $key); + if ($host) { + $query->where('host_id = ?', $host->get('id')); + } + if ($set) { + $query->where('service_set_id = ?', $set->get('id')); + } + $uuid = self::fetchOptionalUuid($connection, $query); + + if ($uuid === null && $branch->isBranch()) { + // TODO: use different tables? + $query = $db->select() + ->from('branched_icinga_service', 'uuid') + ->where('branch_uuid = ?', $connection->quoteBinary($branch->getUuid()->getBytes())); + if ($objectType) { + $query->where('object_type = ?', $objectType); + } + $query = self::addKeyToQuery($connection, $query, $key); + if ($host) { + // TODO: uuid? + $query->where('host = ?', $host->getObjectName()); + } + if ($set) { + $query->where('service_set = ?', $set->getObjectName()); + } + + $uuid = self::fetchOptionalUuid($connection, $query); + } + + return $uuid; + } + + /** + * @param int|string|array $key + * @param string $table + * @param Db $connection + * @param Branch $branch + * @return UuidInterface + * @throws NotFoundError + */ + public static function requireUuidForKey($key, $table, Db $connection, Branch $branch) + { + $uuid = self::findUuidForKey($key, $table, $connection, $branch); + if ($uuid === null) { + throw new NotFoundError('No such object available'); + } + + return $uuid; + } + + /** + * @param int|string|array $key + * @param string $table + * @param Db $connection + * @param Branch $branch + * @return ?UuidInterface + */ + public static function findUuidForKey($key, $table, Db $connection, Branch $branch) + { + $db = $connection->getDbAdapter(); + $query = self::addKeyToQuery($connection, $db->select()->from($table, 'uuid'), $key); + $uuid = self::fetchOptionalUuid($connection, $query); + if ($uuid === null && $branch->isBranch()) { + if (is_array($key) && isset($key['host_id'])) { + $key['host'] = IcingaHost::load($key['host_id'], $connection)->getObjectName(); + unset($key['host_id']); + } + $query = self::addKeyToQuery($connection, $db->select()->from("branched_$table", 'uuid'), $key); + $query->where('branch_uuid = ?', $connection->quoteBinary($branch->getUuid()->getBytes())); + $uuid = self::fetchOptionalUuid($connection, $query); + } + + return $uuid; + } + + protected static function addKeyToQuery(Db $connection, $query, $key) + { + if (is_int($key)) { + $query->where('id = ?', $key); + } elseif (is_string($key)) { + $query->where('object_name = ?', $key); + } else { + foreach ($key as $k => $v) { + $query->where($connection->getDbAdapter()->quoteIdentifier($k) . ' = ?', $v); + } + } + + return $query; + } + + protected static function fetchOptionalUuid(Db $connection, $query) + { + $result = $connection->getDbAdapter()->fetchOne($query); + if (is_resource($result)) { + $result = stream_get_contents($result); + } + if (is_string($result)) { + return Uuid::fromBytes($result); + } + + return null; + } +} diff --git a/library/Director/Db/Cache/CustomVariableCache.php b/library/Director/Db/Cache/CustomVariableCache.php new file mode 100644 index 0000000..243ecae --- /dev/null +++ b/library/Director/Db/Cache/CustomVariableCache.php @@ -0,0 +1,84 @@ +<?php + +namespace Icinga\Module\Director\Db\Cache; + +use Icinga\Application\Benchmark; +use Icinga\Module\Director\CustomVariable\CustomVariables; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; + +class CustomVariableCache +{ + protected $type; + + protected $rowsById = array(); + + protected $varsById = array(); + + public function __construct(IcingaObject $object) + { + Benchmark::measure('Initializing CustomVariableCache'); + $connection = $object->getConnection(); + $db = $connection->getDbAdapter(); + + $columns = array( + 'id' => sprintf('v.%s', $object->getVarsIdColumn()), + 'varname' => 'v.varname', + 'varvalue' => 'v.varvalue', + 'format' => 'v.format', + 'checksum' => '(NULL)', + ); + + if ($connection->isPgsql()) { + if ($connection->hasPgExtension('pgcrypto')) { + $columns['checksum'] = "DIGEST(v.varvalue || ';' || v.format, 'sha1')"; + } + } else { + $columns['checksum'] = "UNHEX(SHA1(v.varvalue || ';' || v.format))"; + } + + $query = $db->select()->from( + array('v' => $object->getVarsTableName()), + $columns + ); + + foreach ($db->fetchAll($query) as $row) { + $id = $row->id; + unset($row->id); + + if (is_resource($row->checksum)) { + $row->checksum = stream_get_contents($row->checksum); + } + + if (array_key_exists($id, $this->rowsById)) { + $this->rowsById[$id][] = $row; + } else { + $this->rowsById[$id] = array($row); + } + } + + Benchmark::measure('Filled CustomVariableCache'); + } + + public function getVarsForObject(IcingaObject $object) + { + $id = $object->id; + + if (array_key_exists($id, $this->rowsById)) { + if (! array_key_exists($id, $this->varsById)) { + $this->varsById[$id] = CustomVariables::forStoredRows( + $this->rowsById[$id] + ); + } + + return $this->varsById[$id]; + } else { + return new CustomVariables(); + } + } + + public function __destruct() + { + unset($this->db); + } +} diff --git a/library/Director/Db/Cache/GroupMembershipCache.php b/library/Director/Db/Cache/GroupMembershipCache.php new file mode 100644 index 0000000..d6d9e8b --- /dev/null +++ b/library/Director/Db/Cache/GroupMembershipCache.php @@ -0,0 +1,104 @@ +<?php + +namespace Icinga\Module\Director\Db\Cache; + +use Icinga\Application\Benchmark; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; + +class GroupMembershipCache +{ + protected $type; + + protected $table; + + protected $groupClass; + + protected $memberships; + + /** @var Db Director database connection */ + protected $connection; + + public function __construct(IcingaObject $object) + { + $this->table = $object->getTableName(); + $this->type = $object->getShortTableName(); + + $this->groupClass = 'Icinga\\Module\\Director\\Objects\\Icinga' + . ucfirst($this->type) . 'Group'; + + Benchmark::measure('Initializing GroupMemberShipCache'); + $this->connection = $object->getConnection(); + $this->loadAllMemberships(); + Benchmark::measure('Filled GroupMemberShipCache'); + } + + protected function loadAllMemberships() + { + $db = $this->connection->getDbAdapter(); + $this->memberships = array(); + + $type = $this->type; + $table = $this->table; + + $query = $db->select()->from( + array('o' => $table), + array( + 'object_id' => 'o.id', + 'group_id' => 'g.id', + 'group_name' => 'g.object_name', + ) + )->join( + array('go' => $table . 'group_' . $type), + 'o.id = go.' . $type . '_id', + array() + )->join( + array('g' => $table . 'group'), + 'go.' . $type . 'group_id = g.id', + array() + )->order('g.object_name'); + + foreach ($db->fetchAll($query) as $row) { + if (! array_key_exists($row->object_id, $this->memberships)) { + $this->memberships[$row->object_id] = array(); + } + + $this->memberships[$row->object_id][$row->group_id] = $row->group_name; + } + } + + public function listGroupNamesForObject(IcingaObject $object) + { + if (array_key_exists($object->id, $this->memberships)) { + return array_values($this->memberships[$object->id]); + } + + return array(); + } + + public function listGroupIdsForObject(IcingaObject $object) + { + if (array_key_exists($object->id, $this->memberships)) { + return array_keys($this->memberships[$object->id]); + } + + return array(); + } + + public function getGroupsForObject(IcingaObject $object) + { + $groups = array(); + $class = $this->groupClass; + foreach ($this->listGroupIdsForObject($object) as $id) { + $object = $class::loadWithAutoIncId($id, $this->connection); + $groups[$object->object_name] = $object; + } + + return $groups; + } + + public function __destruct() + { + unset($this->connection); + } +} diff --git a/library/Director/Db/Cache/PrefetchCache.php b/library/Director/Db/Cache/PrefetchCache.php new file mode 100644 index 0000000..aa9f950 --- /dev/null +++ b/library/Director/Db/Cache/PrefetchCache.php @@ -0,0 +1,166 @@ +<?php + +namespace Icinga\Module\Director\Db\Cache; + +use Icinga\Module\Director\CustomVariable\CustomVariable; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\HostServiceBlacklist; +use Icinga\Module\Director\Resolver\TemplateTree; +use LogicException; + +/** + * Central prefetch cache + * + * Might be improved, accept various caches based on an interface and then + * finally replace prefetch logic in DbObject itself. This would also allow + * to get rid of IcingaObject-related code in this place + */ +class PrefetchCache +{ + protected $db; + + protected static $instance; + + protected $varsCaches = array(); + + protected $groupsCaches = array(); + + protected $templateResolvers = array(); + + protected $renderedVars = array(); + + protected $templateTrees = array(); + + protected $hostServiceBlacklist; + + public static function initialize(Db $db) + { + self::forget(); + self::$instance = new static($db); + } + + protected function __construct(Db $db) + { + $this->db = $db; + } + + /** + * @throws LogicException + * + * @return self + */ + public static function instance() + { + if (static::$instance === null) { + throw new LogicException('Prefetch cache has not been loaded'); + } + + return static::$instance; + } + + public static function forget() + { + DbObject::clearAllPrefetchCaches(); + self::$instance = null; + } + + public static function shouldBeUsed() + { + return self::$instance !== null; + } + + public function vars(IcingaObject $object) + { + return $this->varsCache($object)->getVarsForObject($object); + } + + public function groups(IcingaObject $object) + { + return $this->groupsCache($object)->getGroupsForObject($object); + } + + /* Hint: not implemented, this happens in DbObject right now + public function byObjectType($type) + { + if (! array_key_exists($type, $this->caches)) { + $this->caches[$type] = new ObjectCache($type); + } + + return $this->caches[$type]; + } + */ + + public function renderVar(CustomVariable $var, $renderExpressions = false) + { + $checksum = $var->getChecksum(); + if (null === $checksum) { + return $var->toConfigString($renderExpressions); + } else { + $checksum .= (int) $renderExpressions; + if (! array_key_exists($checksum, $this->renderedVars)) { + $this->renderedVars[$checksum] = $var->toConfigString($renderExpressions); + } + + return $this->renderedVars[$checksum]; + } + } + + public function hostServiceBlacklist() + { + if ($this->hostServiceBlacklist === null) { + $this->hostServiceBlacklist = new HostServiceBlacklist($this->db); + $this->hostServiceBlacklist->preloadMappings(); + } + + return $this->hostServiceBlacklist; + } + + /** + * @param IcingaObject $object + * @return CustomVariableCache + */ + protected function varsCache(IcingaObject $object) + { + $key = $object->getShortTableName(); + + if (! array_key_exists($key, $this->varsCaches)) { + $this->varsCaches[$key] = new CustomVariableCache($object); + } + + return $this->varsCaches[$key]; + } + + protected function groupsCache(IcingaObject $object) + { + $key = $object->getShortTableName(); + + if (! array_key_exists($key, $this->groupsCaches)) { + $this->groupsCaches[$key] = new GroupMembershipCache($object); + } + + return $this->groupsCaches[$key]; + } + + protected function templateTree(IcingaObject $object) + { + $key = $object->getShortTableName(); + if (! array_key_exists($key, $this->templateTrees)) { + $this->templateTrees[$key] = new TemplateTree( + $key, + $object->getConnection() + ); + } + + return $this->templateTrees[$key]; + } + + public function __destruct() + { + unset($this->groupsCaches); + unset($this->varsCaches); + unset($this->templateResolvers); + unset($this->renderedVars); + } +} diff --git a/library/Director/Db/DbSelectParenthesis.php b/library/Director/Db/DbSelectParenthesis.php new file mode 100644 index 0000000..191ad85 --- /dev/null +++ b/library/Director/Db/DbSelectParenthesis.php @@ -0,0 +1,24 @@ +<?php + +namespace Icinga\Module\Director\Db; + +class DbSelectParenthesis extends \Zend_Db_Expr +{ + protected $select; + + public function __construct(\Zend_Db_Select $select) + { + parent::__construct(''); + $this->select = $select; + } + + public function getSelect() + { + return $this->select; + } + + public function __toString() + { + return '(' . $this->select . ')'; + } +} diff --git a/library/Director/Db/DbUtil.php b/library/Director/Db/DbUtil.php new file mode 100644 index 0000000..f98e213 --- /dev/null +++ b/library/Director/Db/DbUtil.php @@ -0,0 +1,96 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use gipfl\ZfDb\Adapter\Adapter; +use gipfl\ZfDb\Adapter\Pdo\Pgsql; +use gipfl\ZfDb\Expr; +use Zend_Db_Adapter_Abstract; +use Zend_Db_Adapter_Pdo_Pgsql; +use Zend_Db_Expr; +use function bin2hex; +use function is_array; +use function is_resource; +use function stream_get_contents; + +class DbUtil +{ + public static function binaryResult($value) + { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + + /** + * @param string|array $binary + * @param Zend_Db_Adapter_Abstract $db + * @return Zend_Db_Expr|Zend_Db_Expr[] + */ + public static function quoteBinaryLegacy($binary, $db) + { + if (is_array($binary)) { + return static::quoteArray($binary, 'quoteBinaryLegacy', $db); + } + + if ($binary === null) { + return null; + } + + if ($db instanceof Zend_Db_Adapter_Pdo_Pgsql) { + return new Zend_Db_Expr("'\\x" . bin2hex($binary) . "'"); + } + + return new Zend_Db_Expr('0x' . bin2hex($binary)); + } + + /** + * @param string|array $binary + * @param Adapter $db + * @return Expr|Expr[] + */ + public static function quoteBinary($binary, $db) + { + if (is_array($binary)) { + return static::quoteArray($binary, 'quoteBinary', $db); + } + + if ($binary === null) { + return null; + } + + if ($db instanceof Pgsql) { + return new Expr("'\\x" . bin2hex($binary) . "'"); + } + + return new Expr('0x' . bin2hex($binary)); + } + + /** + * @param string|array $binary + * @param Adapter|Zend_Db_Adapter_Abstract $db + * @return Expr|Zend_Db_Expr|Expr[]|Zend_Db_Expr[] + */ + public static function quoteBinaryCompat($binary, $db) + { + if ($db instanceof Adapter) { + return static::quoteBinary($binary, $db); + } + + return static::quoteBinaryLegacy($binary, $db); + } + + protected static function quoteArray($array, $method, $db) + { + $result = []; + foreach ($array as $bin) { + $quoted = static::$method($bin, $db); + $result[] = $quoted; + } + + return $result; + } +} diff --git a/library/Director/Db/HostMembershipHousekeeping.php b/library/Director/Db/HostMembershipHousekeeping.php new file mode 100644 index 0000000..3a2de05 --- /dev/null +++ b/library/Director/Db/HostMembershipHousekeeping.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Db; + +class HostMembershipHousekeeping extends MembershipHousekeeping +{ + protected $type = 'host'; +} diff --git a/library/Director/Db/Housekeeping.php b/library/Director/Db/Housekeeping.php new file mode 100644 index 0000000..82fd6b9 --- /dev/null +++ b/library/Director/Db/Housekeeping.php @@ -0,0 +1,249 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; + +class Housekeeping +{ + /** + * @var Db + */ + protected $connection; + + /** + * @var \Zend_Db_Adapter_Abstract + */ + protected $db; + + /** + * @var int + */ + protected $version; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function getTaskSummary() + { + $summary = array(); + foreach ($this->listTasks() as $name => $title) { + $func = 'count' . ucfirst($name); + $summary[$name] = (object) array( + 'name' => $name, + 'title' => $title, + 'count' => $this->$func() + ); + } + + return $summary; + } + + public function listTasks() + { + return array( + 'oldUndeployedConfigs' => N_('Undeployed configurations'), + 'unusedFiles' => N_('Unused rendered files'), + 'unlinkedImportedRowSets' => N_('Unlinked imported row sets'), + 'unlinkedImportedRows' => N_('Unlinked imported rows'), + 'unlinkedImportedProperties' => N_('Unlinked imported properties'), + 'resolveCache' => N_('(Host) group resolve cache'), + ); + } + + public function getPendingTaskSummary() + { + return array_filter( + $this->getTaskSummary(), + function ($task) { + return $task->count > 0; + } + ); + } + + public function hasPendingTasks() + { + return count($this->getPendingTaskSummary()) > 0; + } + + public function runAllTasks() + { + $result = array(); + + foreach ($this->listTasks() as $name => $task) { + $this->runTask($name); + } + + return $this; + } + + public function runTask($name) + { + $func = 'wipe' . ucfirst($name); + if (!method_exists($this, $func)) { + throw new NotFoundError( + 'There is no such task: %s', + $name + ); + } + + return $this->$func(); + } + + public function countOldUndeployedConfigs() + { + $conn = $this->connection; + $lastActivity = $conn->getLastActivityChecksum(); + + $sql = 'SELECT COUNT(*) FROM director_generated_config c' + . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum' + . ' WHERE d.config_checksum IS NULL' + . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum'); + + return $this->db->fetchOne($sql, $lastActivity); + } + + public function wipeOldUndeployedConfigs() + { + $conn = $this->connection; + $lastActivity = $conn->getLastActivityChecksum(); + + if ($this->connection->isPgsql()) { + $sql = 'DELETE FROM director_generated_config' + . ' USING director_generated_config AS c' + . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum' + . ' WHERE director_generated_config.checksum = c.checksum' + . ' AND d.config_checksum IS NULL' + . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum'); + } else { + $sql = 'DELETE c.* FROM director_generated_config c' + . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum' + . ' WHERE d.config_checksum IS NULL' + . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum'); + } + + return $this->db->query($sql, $lastActivity); + } + + public function countUnusedFiles() + { + $sql = 'SELECT COUNT(*) FROM director_generated_file f' + . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum' + . ' WHERE cf.file_checksum IS NULL'; + + return $this->db->fetchOne($sql); + } + + public function wipeUnusedFiles() + { + if ($this->connection->isPgsql()) { + $sql = 'DELETE FROM director_generated_file' + . ' USING director_generated_file AS f' + . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum' + . ' WHERE director_generated_file.checksum = f.checksum' + . ' AND cf.file_checksum IS NULL'; + } else { + $sql = 'DELETE f FROM director_generated_file f' + . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum' + . ' WHERE cf.file_checksum IS NULL'; + } + + return $this->db->exec($sql); + } + + public function countUnlinkedImportedRowSets() + { + $sql = 'SELECT COUNT(*) FROM imported_rowset rs LEFT JOIN import_run r' + . ' ON r.rowset_checksum = rs.checksum WHERE r.id IS NULL'; + + return $this->db->fetchOne($sql); + } + + public function wipeUnlinkedImportedRowSets() + { + // This one removes imported_rowset and imported_rowset_row + // entries no longer used by any historic import<F12> + if ($this->connection->isPgsql()) { + $sql = 'DELETE FROM imported_rowset' + . ' USING imported_rowset AS rs' + . ' LEFT JOIN import_run r ON r.rowset_checksum = rs.checksum' + . ' WHERE imported_rowset.checksum = rs.checksum' + . ' AND r.id IS NULL'; + } else { + $sql = 'DELETE rs.* FROM imported_rowset rs' + . ' LEFT JOIN import_run r ON r.rowset_checksum = rs.checksum' + . ' WHERE r.id IS NULL'; + } + + return $this->db->exec($sql); + } + + public function countUnlinkedImportedRows() + { + $sql = 'SELECT COUNT(*) FROM imported_row r LEFT JOIN imported_rowset_row rsr' + . ' ON rsr.row_checksum = r.checksum WHERE rsr.row_checksum IS NULL'; + + return $this->db->fetchOne($sql); + } + + public function wipeUnlinkedImportedRows() + { + // This query removes imported_row and imported_row_property columns + // without related rowset + if ($this->connection->isPgsql()) { + $sql = 'DELETE FROM imported_row' + . ' USING imported_row AS r' + . ' LEFT JOIN imported_rowset_row rsr ON rsr.row_checksum = r.checksum' + . ' WHERE imported_row.checksum = r.checksum' + . ' AND rsr.row_checksum IS NULL'; + } else { + $sql = 'DELETE r.* FROM imported_row r' + . ' LEFT JOIN imported_rowset_row rsr ON rsr.row_checksum = r.checksum' + . ' WHERE rsr.row_checksum IS NULL'; + } + + return $this->db->exec($sql); + } + + public function countUnlinkedImportedProperties() + { + $sql = 'SELECT COUNT(*) FROM imported_property p LEFT JOIN imported_row_property rp' + . ' ON rp.property_checksum = p.checksum WHERE rp.property_checksum IS NULL'; + + return $this->db->fetchOne($sql); + } + + public function wipeUnlinkedImportedProperties() + { + // This query removes unlinked imported properties + if ($this->connection->isPgsql()) { + $sql = 'DELETE FROM imported_property' + . ' USING imported_property AS p' + . ' LEFT JOIN imported_row_property rp ON rp.property_checksum = p.checksum' + . ' WHERE imported_property.checksum = p.checksum' + . ' AND rp.property_checksum IS NULL'; + } else { + $sql = 'DELETE p.* FROM imported_property p' + . ' LEFT JOIN imported_row_property rp ON rp.property_checksum = p.checksum' + . ' WHERE rp.property_checksum IS NULL'; + } + + return $this->db->exec($sql); + } + + public function countResolveCache() + { + $helper = MembershipHousekeeping::instance('host', $this->connection); + return array_sum($helper->check()); + } + + public function wipeResolveCache() + { + $helper = MembershipHousekeeping::instance('host', $this->connection); + return $helper->update(); + } +} diff --git a/library/Director/Db/IcingaObjectFilterHelper.php b/library/Director/Db/IcingaObjectFilterHelper.php new file mode 100644 index 0000000..2eef406 --- /dev/null +++ b/library/Director/Db/IcingaObjectFilterHelper.php @@ -0,0 +1,133 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\TemplateTree; +use InvalidArgumentException; +use RuntimeException; +use Zend_Db_Select as ZfSelect; + +class IcingaObjectFilterHelper +{ + const INHERIT_DIRECT = 'direct'; + const INHERIT_INDIRECT = 'indirect'; + const INHERIT_DIRECT_OR_INDIRECT = 'total'; + + /** + * @param IcingaObject|int|string $id + * @return int + */ + public static function wantId($id) + { + if (is_int($id)) { + return $id; + } elseif ($id instanceof IcingaObject) { + return (int) $id->get('id'); + } elseif (is_string($id) && ctype_digit($id)) { + return (int) $id; + } else { + throw new InvalidArgumentException(sprintf( + 'Numeric ID or IcingaObject expected, got %s', + // TODO: just type/class info? + var_export($id, 1) + )); + } + } + + /** + * @param ZfSelect $query + * @param IcingaObject|int|string $template + * @param string $tableAlias + * @param string $inheritanceType + * @return ZfSelect + */ + public static function filterByTemplate( + ZfSelect $query, + $template, + $tableAlias = 'o', + $inheritanceType = self::INHERIT_DIRECT + ) { + $i = $tableAlias . 'i'; + $o = $tableAlias; + $type = $template->getShortTableName(); + $db = $template->getDb(); + $id = static::wantId($template); + $sub = $db->select()->from( + array($i => "icinga_${type}_inheritance"), + array('e' => '(1)') + )->where("$i.${type}_id = $o.id"); + + if ($inheritanceType === self::INHERIT_DIRECT) { + $sub->where("$i.parent_${type}_id = ?", $id); + } elseif ($inheritanceType === self::INHERIT_INDIRECT + || $inheritanceType === self::INHERIT_DIRECT_OR_INDIRECT + ) { + $tree = new TemplateTree($type, $template->getConnection()); + $ids = $tree->listDescendantIdsFor($template); + if ($inheritanceType === self::INHERIT_DIRECT_OR_INDIRECT) { + $ids[] = $template->getAutoincId(); + } + + if (empty($ids)) { + $sub->where('(1 = 0)'); + } else { + $sub->where("$i.parent_${type}_id IN (?)", $ids); + } + } else { + throw new RuntimeException(sprintf( + 'Unable to understand "%s" inheritance', + $inheritanceType + )); + } + + return $query->where('EXISTS ?', $sub); + } + + public static function filterByHostgroups( + ZfSelect $query, + $type, + $groups, + $tableAlias = 'o' + ) { + if (empty($groups)) { + // Asked for an empty set of groups? Give no result + $query->where('(1 = 0)'); + } else { + $sub = $query->getAdapter()->select()->from( + array('go' => "icinga_${type}group_${type}"), + array('e' => '(1)') + )->join( + array('g' => "icinga_${type}group"), + "go.${type}group_id = g.id" + )->where("go.${type}_id = ${tableAlias}.id") + ->where('g.object_name IN (?)', $groups); + + $query->where('EXISTS ?', $sub); + } + } + + public static function filterByResolvedHostgroups( + ZfSelect $query, + $type, + $groups, + $tableAlias = 'o' + ) { + if (empty($groups)) { + // Asked for an empty set of groups? Give no result + $query->where('(1 = 0)'); + } else { + $sub = $query->getAdapter()->select()->from( + array('go' => "icinga_${type}group_${type}_resolved"), + array('e' => '(1)') + )->join( + array('g' => "icinga_${type}group"), + "go.${type}group_id = g.id", + [] + )->where("go.${type}_id = ${tableAlias}.id") + ->where('g.object_name IN (?)', $groups); + + $query->where('EXISTS ?', $sub); + } + } +} diff --git a/library/Director/Db/MembershipHousekeeping.php b/library/Director/Db/MembershipHousekeeping.php new file mode 100644 index 0000000..4d1ae88 --- /dev/null +++ b/library/Director/Db/MembershipHousekeeping.php @@ -0,0 +1,135 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Data\Db\DbConnection; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Objects\GroupMembershipResolver; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaObjectGroup; + +abstract class MembershipHousekeeping +{ + protected $type; + + protected $groupType; + + protected $connection; + + /** @var GroupMembershipResolver */ + protected $resolver; + + /** @var IcingaObject[] */ + protected $objects; + + /** @var IcingaObjectGroup[] */ + protected $groups; + + protected $prepared = false; + + protected static $instances = []; + + public function __construct(Db $connection) + { + $this->connection = $connection; + + if ($this->groupType === null) { + $this->groupType = $this->type . 'Group'; + } + } + + /** + * @param string $type + * @param DbConnection $connection + * + * @return static + */ + public static function instance($type, $connection) + { + if (! array_key_exists($type, self::$instances)) { + /** @var MembershipHousekeeping $class */ + $class = 'Icinga\\Module\\Director\\Db\\' . ucfirst($type) . 'MembershipHousekeeping'; + + /** @var MembershipHousekeeping $helper */ + self::$instances[$type] = new $class($connection); + } + + return self::$instances[$type]; + } + + protected function prepare() + { + if ($this->prepared) { + return $this; + } + + $this->prepareCache(); + $this->resolver()->defer(); + + $this->objects = IcingaObject::loadAllByType($this->type, $this->connection); + $this->resolver()->addObjects($this->objects); + + $this->groups = IcingaObject::loadAllByType($this->groupType, $this->connection); + $this->resolver()->addGroups($this->groups); + + MemoryLimit::raiseTo('1024M'); + + $this->prepared = true; + + return $this; + } + + public function check() + { + $this->prepare(); + + $resolver = $this->resolver()->checkDb(); + + return array($resolver->getNewMappings(), $resolver->getOutdatedMappings()); + } + + public function update() + { + $this->prepare(); + + $this->resolver()->refreshDb(true); + + return true; + } + + protected function prepareCache() + { + PrefetchCache::initialize($this->connection); + + IcingaObject::prefetchAllRelationsByType($this->type, $this->connection); + } + + protected function resolver() + { + if ($this->resolver === null) { + /** @var GroupMembershipResolver $class */ + $class = 'Icinga\\Module\\Director\\Objects\\' . ucfirst($this->type) . 'GroupMembershipResolver'; + $this->resolver = new $class($this->connection); + } + + return $this->resolver; + } + + /** + * @return IcingaObject[] + */ + public function getObjects() + { + return $this->objects; + } + + /** + * @return IcingaObjectGroup[] + */ + public function getGroups() + { + return $this->groups; + } +} diff --git a/library/Director/Db/Migration.php b/library/Director/Db/Migration.php new file mode 100644 index 0000000..5685121 --- /dev/null +++ b/library/Director/Db/Migration.php @@ -0,0 +1,70 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use Exception; +use Icinga\Module\Director\Data\Db\DbConnection; +use RuntimeException; + +class Migration +{ + /** + * @var string + */ + protected $sql; + + /** + * @var int + */ + protected $version; + + public function __construct($version, $sql) + { + $this->version = $version; + $this->sql = $sql; + } + + /** + * @param DbConnection $connection + * @return $this + */ + public function apply(DbConnection $connection) + { + /** @var \Zend_Db_Adapter_Pdo_Abstract $db */ + $db = $connection->getDbAdapter(); + + // TODO: this is fragile and depends on accordingly written schema files: + $queries = preg_split( + '/[\n\s\t]*\;[\n\s\t]+/s', + $this->sql, + -1, + PREG_SPLIT_NO_EMPTY + ); + + if (empty($queries)) { + throw new RuntimeException(sprintf( + 'Migration %d has no queries', + $this->version + )); + } + + try { + foreach ($queries as $query) { + if (preg_match('/^(?:OPTIMIZE|EXECUTE) /i', $query)) { + $db->query($query); + } else { + $db->exec($query); + } + } + } catch (Exception $e) { + throw new RuntimeException(sprintf( + 'Migration %d failed (%s) while running %s', + $this->version, + $e->getMessage(), + $query + )); + } + + return $this; + } +} diff --git a/library/Director/Db/Migrations.php b/library/Director/Db/Migrations.php new file mode 100644 index 0000000..2310408 --- /dev/null +++ b/library/Director/Db/Migrations.php @@ -0,0 +1,239 @@ +<?php + +namespace Icinga\Module\Director\Db; + +use DirectoryIterator; +use Exception; +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Db\DbConnection; +use RuntimeException; + +class Migrations +{ + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** + * @var DbConnection + */ + protected $connection; + + protected $migrationsDir; + + public function __construct(DbConnection $connection) + { + if (version_compare(PHP_VERSION, '5.4.0') < 0) { + throw new RuntimeException( + "PHP version 5.4.x is required for Director >= 1.4.0, you're running %s." + . ' Please either upgrade PHP or downgrade Icinga Director', + PHP_VERSION + ); + } + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function getLastMigrationNumber() + { + try { + $query = $this->db->select()->from( + array('m' => $this->getTableName()), + array('schema_version' => 'MAX(schema_version)') + ); + + return (int) $this->db->fetchOne($query); + } catch (Exception $e) { + return 0; + } + } + + protected function getTableName() + { + return $this->getModuleName() . '_schema_migration'; + } + + public function hasSchema() + { + return $this->listPendingMigrations() !== array(0); + } + + public function hasPendingMigrations() + { + return $this->countPendingMigrations() > 0; + } + + public function countPendingMigrations() + { + return count($this->listPendingMigrations()); + } + + /** + * @return Migration[] + */ + public function getPendingMigrations() + { + $migrations = array(); + foreach ($this->listPendingMigrations() as $version) { + $migrations[] = new Migration( + $version, + $this->loadMigrationFile($version) + ); + } + + return $migrations; + } + + /** + * @return $this + */ + public function applyPendingMigrations() + { + // Ensure we have enough time to migrate + ini_set('max_execution_time', 0); + + foreach ($this->getPendingMigrations() as $migration) { + $migration->apply($this->connection); + } + + return $this; + } + + public function listPendingMigrations() + { + $lastMigration = $this->getLastMigrationNumber(); + if ($lastMigration === 0) { + return array(0); + } + + return $this->listMigrationsAfter($this->getLastMigrationNumber()); + } + + public function listAllMigrations() + { + $dir = $this->getMigrationsDir(); + if (! is_readable($dir)) { + return array(); + } + + $versions = array(); + + foreach (new DirectoryIterator($this->getMigrationsDir()) as $file) { + if ($file->isDot()) { + continue; + } + + $filename = $file->getFilename(); + if (preg_match('/^upgrade_(\d+)\.sql$/', $filename, $match)) { + $versions[] = $match[1]; + } + } + + sort($versions); + + return $versions; + } + + public function loadMigrationFile($version) + { + if ($version === 0) { + $filename = $this->getFullSchemaFile(); + } else { + $filename = $this->getMigrationFileName($version); + } + + return file_get_contents($filename); + } + + public function hasBeenDowngraded() + { + return ! $this->hasMigrationFile($this->getLastMigrationNumber()); + } + + public function hasMigrationFile($version) + { + return \file_exists($this->getMigrationFileName($version)); + } + + protected function getMigrationFileName($version) + { + return sprintf( + '%s/upgrade_%d.sql', + $this->getMigrationsDir(), + $version + ); + } + + protected function listMigrationsAfter($version) + { + $filtered = array(); + foreach ($this->listAllMigrations() as $available) { + if ($available > $version) { + $filtered[] = $available; + } + } + + return $filtered; + } + + protected function getMigrationsDir() + { + if ($this->migrationsDir === null) { + $this->migrationsDir = $this->getSchemaDir( + $this->connection->getDbType() . '-migrations' + ); + } + + return $this->migrationsDir; + } + + protected function getFullSchemaFile() + { + return $this->getSchemaDir( + $this->connection->getDbType() . '.sql' + ); + } + + protected function getSchemaDir($sub = null) + { + try { + $dir = $this->getModuleDir('/schema'); + } catch (ProgrammingError $e) { + throw new RuntimeException( + 'Unable to detect the schema directory for this module', + 0, + $e + ); + } + if ($sub === null) { + return $dir; + } else { + return $dir . '/' . ltrim($sub, '/'); + } + } + + /** + * @param string $sub + * @return string + * @throws ProgrammingError + */ + protected function getModuleDir($sub = '') + { + return Icinga::app()->getModuleManager()->getModuleDir( + $this->getModuleName(), + $sub + ); + } + + protected function getModuleName() + { + return $this->getModuleNameForObject($this); + } + + protected function getModuleNameForObject($object) + { + $class = get_class($object); + // Hint: Icinga\Module\ -> 14 chars + return lcfirst(substr($class, 14, strpos($class, '\\', 15) - 14)); + } +} diff --git a/library/Director/Deployment/ConditionalConfigRenderer.php b/library/Director/Deployment/ConditionalConfigRenderer.php new file mode 100644 index 0000000..0b24418 --- /dev/null +++ b/library/Director/Deployment/ConditionalConfigRenderer.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Director\Deployment; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\DirectorActivityLog; + +class ConditionalConfigRenderer +{ + /** @var Db */ + protected $db; + + protected $forceRendering = false; + + public function __construct(Db $connection) + { + $this->db = $connection; + } + + public function forceRendering($force = true) + { + $this->forceRendering = $force; + + return $this; + } + + public function getConfig() + { + if ($this->shouldGenerate()) { + return IcingaConfig::generate($this->db); + } + + return $this->loadLatestActivityConfig(); + } + + protected function loadLatestActivityConfig() + { + $db = $this->db; + + return IcingaConfig::loadByActivityChecksum($db->getLastActivityChecksum(), $db); + } + + protected function shouldGenerate() + { + return $this->forceRendering || !$this->configForLatestActivityExists(); + } + + protected function configForLatestActivityExists() + { + $db = $this->db; + try { + $latestActivity = DirectorActivityLog::loadLatest($db); + } catch (NotFoundError $e) { + return false; + } + + return IcingaConfig::existsForActivityChecksum( + bin2hex($latestActivity->get('checksum')), + $db + ); + } +} diff --git a/library/Director/Deployment/ConditionalDeployment.php b/library/Director/Deployment/ConditionalDeployment.php new file mode 100644 index 0000000..0f64028 --- /dev/null +++ b/library/Director/Deployment/ConditionalDeployment.php @@ -0,0 +1,190 @@ +<?php + +namespace Icinga\Module\Director\Deployment; + +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; + +class ConditionalDeployment implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + /** @var Db */ + protected $db; + + /** @var CoreApi */ + protected $api; + + /** @var ?DeploymentGracePeriod */ + protected $gracePeriod = null; + + protected $force = false; + + protected $hasBeenForced = false; + + /** @var ?string */ + protected $noDeploymentReason = null; + + public function __construct(Db $connection, CoreApi $api = null) + { + $this->setLogger(new NullLogger()); + $this->db = $connection; + if ($api === null) { + $this->api = $connection->getDeploymentEndpoint()->api(); + } else { + $this->api = $api; + } + $this->refresh(); + } + + /** + * @param IcingaConfig $config + * @return ?DirectorDeploymentLog + */ + public function deploy(IcingaConfig $config) + { + $this->hasBeenForced = false; + if ($this->shouldDeploy($config)) { + return $this->reallyDeploy($config); + } elseif ($this->force) { + $deployment = $this->reallyDeploy($config); + $this->hasBeenForced = true; + + return $deployment; + } + + return null; + } + + /** + * @param bool $force + * @return $this + */ + public function force($force = true) + { + $this->force = $force; + return $this; + } + + public function setGracePeriod(DeploymentGracePeriod $gracePeriod) + { + $this->gracePeriod = $gracePeriod; + return $this; + } + + public function refresh() + { + $this->api->collectLogFiles($this->db); + $this->api->wipeInactiveStages($this->db); + } + + public function waitForStartupAfterDeploy(DirectorDeploymentLog $deploymentLog, $timeout) + { + $startTime = time(); + while ((time() - $startTime) <= $timeout) { + $deploymentFromDB = DirectorDeploymentLog::load($deploymentLog->getId(), $this->db); + $stageCollected = $deploymentFromDB->get('stage_collected'); + if ($stageCollected === null) { + usleep(500000); + continue; + } + if ($stageCollected === 'n') { + return 'stage has not been collected (Icinga "lost" the deployment)'; + } + if ($deploymentFromDB->get('startup_succeeded') === 'y') { + return true; + } + return 'deployment failed during startup (usually a Configuration Error)'; + } + return 'deployment timed out (while waiting for an Icinga restart)'; + } + + /** + * @return string|null + */ + public function getNoDeploymentReason() + { + return $this->noDeploymentReason; + } + + public function hasBeenForced() + { + return $this->hasBeenForced; + } + + protected function shouldDeploy(IcingaConfig $config) + { + $this->noDeploymentReason = null; + if ($this->hasNeverDeployed()) { + return true; + } + + if ($this->isWithinGracePeriod()) { + $this->noDeploymentReason = 'Grace period is active'; + return false; + } + + if ($this->deployedConfigMatches($config)) { + $this->noDeploymentReason = 'Config matches last deployed one'; + return false; + } + + if ($this->getActiveChecksum() === $config->getHexChecksum()) { + $this->noDeploymentReason = 'Config matches active stage'; + return false; + } + + return true; + } + + protected function hasNeverDeployed() + { + return !DirectorDeploymentLog::hasDeployments($this->db); + } + + protected function isWithinGracePeriod() + { + return $this->gracePeriod && $this->gracePeriod->isActive(); + } + + protected function deployedConfigMatches(IcingaConfig $config) + { + if ($deployment = DirectorDeploymentLog::optionalLatest($this->db)) { + return $deployment->getConfigHexChecksum() === $config->getHexChecksum(); + } + + return false; + } + + protected function getActiveChecksum() + { + return DirectorDeploymentLog::getConfigChecksumForStageName( + $this->db, + $this->api->getActiveStageName() + ); + } + + /** + * @param IcingaConfig $config + * @return bool|DirectorDeploymentLog + * @throws IcingaException + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function reallyDeploy(IcingaConfig $config) + { + $checksum = $config->getHexChecksum(); + $this->logger->info(sprintf('Director ConfigJob ready to deploy "%s"', $checksum)); + if ($deployment = $this->api->dumpConfig($config, $this->db)) { + $this->logger->notice(sprintf('Director ConfigJob deployed config "%s"', $checksum)); + return $deployment; + } else { + throw new IcingaException('Failed to deploy config "%s"', $checksum); + } + } +} diff --git a/library/Director/Deployment/DeploymentGracePeriod.php b/library/Director/Deployment/DeploymentGracePeriod.php new file mode 100644 index 0000000..6cde25a --- /dev/null +++ b/library/Director/Deployment/DeploymentGracePeriod.php @@ -0,0 +1,61 @@ +<?php + +namespace Icinga\Module\Director\Deployment; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; + +class DeploymentGracePeriod +{ + /** @var int */ + protected $graceTimeSeconds; + + /** @var Db */ + protected $db; + + /** + * @param int $graceTimeSeconds + * @param Db $db + */ + public function __construct($graceTimeSeconds, Db $db) + { + $this->graceTimeSeconds = $graceTimeSeconds; + $this->db = $db; + } + + /** + * Whether we're still within a grace period + * @return bool + */ + public function isActive() + { + if ($deployment = $this->lastDeployment()) { + return $deployment->getDeploymentTimestamp() > $this->getGracePeriodStart(); + } + + return false; + } + + protected function getGracePeriodStart() + { + return time() - $this->graceTimeSeconds; + } + + public function getRemainingGraceTime() + { + if ($this->isActive()) { + if ($deployment = $this->lastDeployment()) { + return $deployment->getDeploymentTimestamp() - $this->getGracePeriodStart(); + } else { + return null; + } + } + + return 0; + } + + protected function lastDeployment() + { + return DirectorDeploymentLog::optionalLatest($this->db); + } +} diff --git a/library/Director/Deployment/DeploymentInfo.php b/library/Director/Deployment/DeploymentInfo.php new file mode 100644 index 0000000..77d52de --- /dev/null +++ b/library/Director/Deployment/DeploymentInfo.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\Deployment; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; + +class DeploymentInfo +{ + /** @var IcingaObject */ + protected $object; + + protected $db; + + /** @var int */ + protected $totalChanges; + + /** @var int */ + protected $objectChanges; + + public function __construct(Db $db) + { + $this->db = $db; + } + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } + + public function getTotalChanges() + { + if ($this->totalChanges === null) { + $this->totalChanges = $this->db->countActivitiesSinceLastDeployedConfig(); + } + + return $this->totalChanges; + } + + public function getSingleObjectChanges() + { + if ($this->objectChanges === null) { + if ($this->object === null) { + $this->objectChanges = 0; + } else { + $this->objectChanges = $this->db + ->countActivitiesSinceLastDeployedConfig($this->object); + } + } + + return $this->objectChanges; + } + + public function hasUndeployedChanges() + { + return $this->getSingleObjectChanges() > 0 && $this->getTotalChanges() > 0; + } +} diff --git a/library/Director/Deployment/DeploymentStatus.php b/library/Director/Deployment/DeploymentStatus.php new file mode 100644 index 0000000..ae850c6 --- /dev/null +++ b/library/Director/Deployment/DeploymentStatus.php @@ -0,0 +1,164 @@ +<?php + +namespace Icinga\Module\Director\Deployment; + +use Exception; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; + +class DeploymentStatus +{ + protected $db; + + protected $api; + + public function __construct(Db $db, CoreApi $api) + { + $this->db = $db; + $this->api = $api; + } + + public function getDeploymentStatus($configs = null, $activities = null) + { + try { + if (DirectorDeploymentLog::hasUncollected($this->db)) { + $this->api->collectLogFiles($this->db); + } + } catch (Exception $e) { + // Ignore eventual issues while talking to Icinga + } + + $activeConfiguration = null; + $lastActivityLogChecksum = null; + $configChecksum = null; + if ($stageName = $this->api->getActiveStageName()) { + $activityLogChecksum = DirectorDeploymentLog::getRelatedToActiveStage($this->api, $this->db); + if ($activityLogChecksum === null) { + $activeConfiguration = [ + 'stage_name' => $stageName, + 'config' => null, + 'activity' => null + ]; + } else { + $lastActivityLogChecksum = bin2hex($activityLogChecksum->get('last_activity_checksum')); + $configChecksum = $this->getConfigChecksumForStageName($stageName); + $activeConfiguration = [ + 'stage_name' => $stageName, + 'config' => ($configChecksum) ? : null, + 'activity' => $lastActivityLogChecksum + ]; + } + } + $result = [ + 'active_configuration' => (object) $activeConfiguration, + ]; + + if ($configs) { + $result['configs'] = (object) $this->getDeploymentStatusForConfigChecksums( + explode(',', $configs), + $configChecksum + ); + } + + if ($activities) { + $result['activities'] = (object) $this->getDeploymentStatusForActivityLogChecksums( + explode(',', $activities), + $lastActivityLogChecksum + ); + } + return (object) $result; + } + + public function getConfigChecksumForStageName($stageName) + { + $db = $this->db->getDbAdapter(); + $query = $db->select()->from( + ['l' => 'director_deployment_log'], + ['checksum' => $this->db->dbHexFunc('l.config_checksum')] + )->where('l.stage_name = ?', $stageName); + + return $db->fetchOne($query); + } + + public function getDeploymentStatusForConfigChecksums($configChecksums, $activeConfigChecksum) + { + $db = $this->db->getDbAdapter(); + $results = array_combine($configChecksums, array_map(function () { + return 'unknown'; + }, $configChecksums)); + $binaryConfigChecksums = []; + foreach ($configChecksums as $singleConfigChecksum) { + $binaryConfigChecksums[$singleConfigChecksum] = $this->db->quoteBinary(hex2bin($singleConfigChecksum)); + } + $deployedConfigs = $this->getDeployedConfigs(array_values($binaryConfigChecksums)); + + foreach ($results as $singleChecksum => &$status) { + // active if it's equal to the provided active + if ($singleChecksum === $activeConfigChecksum) { + $status = 'active'; + } else { + if (isset($deployedConfigs[$singleChecksum])) { + $status = ($deployedConfigs[$singleChecksum] === 'y') ? 'deployed' : 'failed'; + } else { + // check if it's in generated_config table it is undeployed + $generatedConfigQuery = $db->select()->from( + ['g' => 'director_generated_config'], + ['checksum' => 'g.checksum'] + )->where('g.checksum = ?', $binaryConfigChecksums[$singleChecksum]); + if ($db->fetchOne($generatedConfigQuery)) { + $status = 'undeployed'; + } + } + // otherwise leave unknown + } + } + + return $results; + } + + public function getDeploymentStatusForActivityLogChecksums($activityLogChecksums, $activeActivityLogChecksum) + { + $db = $this->db->getDbAdapter(); + $results = array_combine($activityLogChecksums, array_map(function () { + return 'unknown'; + }, $activityLogChecksums)); + + foreach ($results as $singleActivityLogChecksum => &$status) { + // active if it's equal to the provided active + if ($singleActivityLogChecksum === $activeActivityLogChecksum) { + $status = 'active'; + } else { + // get last deployed activity id and check if it's less than the passed one + $generatedConfigQuery = $db->select()->from( + ['a' => 'director_activity_log'], + ['id' => 'a.id'] + )->where('a.checksum = ?', $this->db->quoteBinary(hex2bin($singleActivityLogChecksum))); + if ($singleActivityLogData = $db->fetchOne($generatedConfigQuery)) { + if ($lastDeploymentActivityLogId = $this->db->getLastDeploymentActivityLogId()) { + if ((int) $singleActivityLogData > $lastDeploymentActivityLogId) { + $status = 'undeployed'; + } else { + $status = 'deployed'; + } + } + } + } + } + return $results; + } + + /** + * @param array $binaryConfigChecksums + * @return array + */ + public function getDeployedConfigs(array $binaryConfigChecksums) + { + $db = $this->db->getDbAdapter(); + $deploymentLogQuery = $db->select()->from(['l' => 'director_deployment_log'], [ + 'checksum' => $this->db->dbHexFunc('l.config_checksum'), + 'deployed' => 'l.startup_succeeded' + ])->where('l.config_checksum IN (?)', $binaryConfigChecksums); + return $db->fetchPairs($deploymentLogQuery); + } +} diff --git a/library/Director/DirectorObject/Automation/Basket.php b/library/Director/DirectorObject/Automation/Basket.php new file mode 100644 index 0000000..f7eb8e5 --- /dev/null +++ b/library/Director/DirectorObject/Automation/Basket.php @@ -0,0 +1,232 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +/** + * Class Basket + * + * TODO + * - create a UUID like in RFC4122 + */ +class Basket extends DbObject implements ExportInterface +{ + const SELECTION_ALL = true; + const SELECTION_NONE = false; + + protected $table = 'director_basket'; + + protected $keyName = 'basket_name'; + + protected $chosenObjects = []; + + protected $protectedFormerChosenObjects; + + protected $defaultProperties = [ + 'uuid' => null, + 'basket_name' => null, + 'objects' => null, + 'owner_type' => null, + 'owner_value' => null, + ]; + + protected $binaryProperties = [ + 'uuid' + ]; + + public function getHexUuid() + { + return bin2hex($this->get('uuid')); + } + + public function listObjectTypes() + { + return array_keys($this->objects); + } + + public function getChosenObjects() + { + return $this->chosenObjects; + } + + public function isEmpty() + { + return count($this->getChosenObjects()) === 0; + } + + protected function onLoadFromDb() + { + $this->chosenObjects = (array) Json::decode($this->get('objects')); + unset($this->chosenObjects['Datafield']); // Might be in old baskets + } + + public function getUniqueIdentifier() + { + return $this->get('basket_name'); + } + + public function export() + { + $result = $this->getProperties(); + unset($result['uuid']); + $result['objects'] = Json::decode($result['objects']); + ksort($result); + + return (object) $result; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['basket_name']; + + if ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::exists($name, $db)) { + throw new DuplicateKeyException( + 'Basket "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + $object->setProperties($properties); + + return $object; + } + + public function supportsCustomSelectionFor($type) + { + if (! array_key_exists($type, $this->chosenObjects)) { + return false; + } + + return is_array($this->chosenObjects[$type]); + } + + public function setObjects($objects) + { + if (empty($objects)) { + $this->chosenObjects = []; + } else { + $this->protectedFormerChosenObjects = $this->chosenObjects; + $this->chosenObjects = []; + foreach ((array) $objects as $type => $object) { + $this->addObjects($type, $object); + } + } + + return $this; + } + + /** + * This is a weird method, as it is required to deal with raw form data + * + * @param $type + * @param ExportInterface[]|bool $objects + */ + public function addObjects($type, $objects = true) + { + BasketSnapshot::assertValidType($type); + // '1' -> from Form! + if ($objects === 'ALL') { + $objects = true; + } elseif ($objects === null || $objects === 'IGNORE') { + return; + } elseif ($objects === '[]' || is_array($objects)) { + if (! isset($this->chosenObjects[$type]) || ! is_array($this->chosenObjects[$type])) { + $this->chosenObjects[$type] = []; + } + if (isset($this->protectedFormerChosenObjects[$type])) { + if (is_array($this->protectedFormerChosenObjects[$type])) { + $this->chosenObjects[$type] = $this->protectedFormerChosenObjects[$type]; + } else { + $this->chosenObjects[$type] = []; + } + } + + if ($objects === '[]') { + $objects = []; + } + } + + if ($objects === true) { + $this->chosenObjects[$type] = true; + } elseif ($objects === '0') { + // nothing + } else { + foreach ($objects as $object) { + $this->addObject($type, $object); + } + + if (array_key_exists($type, $this->chosenObjects)) { + ksort($this->chosenObjects[$type]); + } + } + + $this->reallySet('objects', Json::encode($this->chosenObjects)); + } + + public function hasObject($type, $object) + { + if (! $this->hasType($type)) { + return false; + } + + if ($this->chosenObjects[$type] === true) { + return true; + } + + if ($object instanceof ExportInterface) { + $object = $object->getUniqueIdentifier(); + } + + if (is_array($this->chosenObjects[$type])) { + return in_array($object, $this->chosenObjects[$type]); + } else { + return false; + } + } + + /** + * @param $type + * @param string $object + */ + public function addObject($type, $object) + { + if (is_array($this->chosenObjects[$type])) { + $this->chosenObjects[$type][] = $object; + } else { + throw new \InvalidArgumentException(sprintf( + 'The Basket "%s" has not been configured for single objects of type "%s"', + $this->get('basket_name'), + $type + )); + } + } + + public function hasType($type) + { + return isset($this->chosenObjects[$type]); + } + + protected function beforeStore() + { + if (! $this->hasBeenLoadedFromDb()) { + // TODO: This is BS, use a real UUID + $this->set('uuid', hex2bin(substr(sha1(microtime(true) . rand(1, 100000)), 0, 32))); + } + } +} diff --git a/library/Director/DirectorObject/Automation/BasketContent.php b/library/Director/DirectorObject/Automation/BasketContent.php new file mode 100644 index 0000000..e59c0ae --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketContent.php @@ -0,0 +1,24 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Data\Db\DbObject; + +class BasketContent extends DbObject +{ + protected $objects; + + protected $table = 'director_basket_content'; + + protected $keyName = 'checksum'; + + protected $defaultProperties = [ + 'checksum' => null, + 'summary' => null, + 'content' => null, + ]; + + protected $binaryProperties = [ + 'checksum' + ]; +} diff --git a/library/Director/DirectorObject/Automation/BasketSnapshot.php b/library/Director/DirectorObject/Automation/BasketSnapshot.php new file mode 100644 index 0000000..4ddf2ce --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshot.php @@ -0,0 +1,531 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use gipfl\Json\JsonEncodeException; +use gipfl\Json\JsonString; +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorDatafieldCategory; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaDependency; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaHostGroup; +use Icinga\Module\Director\Objects\IcingaNotification; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceGroup; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Objects\IcingaTemplateChoiceHost; +use Icinga\Module\Director\Objects\IcingaTemplateChoiceService; +use Icinga\Module\Director\Objects\IcingaTimePeriod; +use Icinga\Module\Director\Objects\IcingaUser; +use Icinga\Module\Director\Objects\IcingaUserGroup; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\SyncRule; +use InvalidArgumentException; +use RuntimeException; + +class BasketSnapshot extends DbObject +{ + protected static $typeClasses = [ + 'DatafieldCategory' => DirectorDatafieldCategory::class, + 'Datafield' => DirectorDatafield::class, + 'TimePeriod' => IcingaTimePeriod::class, + 'CommandTemplate' => [IcingaCommand::class, ['object_type' => 'template']], + 'ExternalCommand' => [IcingaCommand::class, ['object_type' => 'external_object']], + 'Command' => [IcingaCommand::class, ['object_type' => 'object']], + 'HostGroup' => IcingaHostGroup::class, + 'IcingaTemplateChoiceHost' => IcingaTemplateChoiceHost::class, + 'HostTemplate' => IcingaHost::class, + 'ServiceGroup' => IcingaServiceGroup::class, + 'IcingaTemplateChoiceService' => IcingaTemplateChoiceService::class, + 'ServiceTemplate' => IcingaService::class, + 'ServiceSet' => IcingaServiceSet::class, + 'UserGroup' => IcingaUserGroup::class, + 'UserTemplate' => [IcingaUser::class, ['object_type' => 'template']], + 'User' => [IcingaUser::class, ['object_type' => 'object']], + 'NotificationTemplate' => IcingaNotification::class, + 'Notification' => [IcingaNotification::class, ['object_type' => 'apply']], + 'DataList' => DirectorDatalist::class, + 'Dependency' => IcingaDependency::class, + 'ImportSource' => ImportSource::class, + 'SyncRule' => SyncRule::class, + 'DirectorJob' => DirectorJob::class, + 'Basket' => Basket::class, + ]; + + protected $objects = []; + + protected $content; + + protected $table = 'director_basket_snapshot'; + + protected $keyName = [ + 'basket_uuid', + 'ts_create', + ]; + + protected $restoreOrder = [ + 'CommandTemplate', + 'ExternalCommand', + 'Command', + 'TimePeriod', + 'HostGroup', + 'IcingaTemplateChoiceHost', + 'HostTemplate', + 'ServiceGroup', + 'IcingaTemplateChoiceService', + 'ServiceTemplate', + 'ServiceSet', + 'UserGroup', + 'UserTemplate', + 'User', + 'NotificationTemplate', + 'Notification', + 'Dependency', + 'ImportSource', + 'SyncRule', + 'DirectorJob', + 'Basket', + ]; + + protected $defaultProperties = [ + 'basket_uuid' => null, + 'content_checksum' => null, + 'ts_create' => null, + ]; + + protected $binaryProperties = [ + 'basket_uuid', + 'content_checksum', + ]; + + public static function supports($type) + { + return isset(self::$typeClasses[$type]); + } + + public static function assertValidType($type) + { + if (! static::supports($type)) { + throw new InvalidArgumentException("Basket does not support '$type'"); + } + } + + public static function getClassForType($type) + { + static::assertValidType($type); + + if (is_array(self::$typeClasses[$type])) { + return self::$typeClasses[$type][0]; + } + + return self::$typeClasses[$type]; + } + + public static function getClassAndObjectTypeForType($type) + { + if (is_array(self::$typeClasses[$type])) { + return self::$typeClasses[$type]; + } + + return [self::$typeClasses[$type], null]; + } + + /** + * @param Basket $basket + * @param Db $db + * @return BasketSnapshot + * @throws \Icinga\Exception\NotFoundError + */ + public static function createForBasket(Basket $basket, Db $db) + { + $snapshot = static::create([ + 'basket_uuid' => $basket->get('uuid') + ], $db); + $snapshot->addObjectsChosenByBasket($basket); + $snapshot->resolveRequiredFields(); + + return $snapshot; + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function resolveRequiredFields() + { + /** @var Db $db */ + $db = $this->getConnection(); + $fieldResolver = new BasketSnapshotFieldResolver($this->objects, $db); + /** @var DirectorDatafield[] $fields */ + $fields = $fieldResolver->loadCurrentFields($db); + $categories = []; + if (! empty($fields)) { + $plain = []; + foreach ($fields as $id => $field) { + $plain[$id] = $field->export(); + if ($category = $field->getCategory()) { + $categories[$category->get('category_name')] = $category->export(); + } + } + $this->objects['Datafield'] = $plain; + } + if (! empty($categories)) { + $this->objects['DatafieldCategory'] = $categories; + } + } + + protected function addObjectsChosenByBasket(Basket $basket) + { + foreach ($basket->getChosenObjects() as $typeName => $selection) { + if ($selection === true) { + $this->addAll($typeName); + } elseif (! empty($selection)) { + $this->addByIdentifiers($typeName, $selection); + } + } + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + protected function beforeStore() + { + if ($this->hasBeenLoadedFromDb()) { + throw new RuntimeException('A basket snapshot cannot be modified'); + } + $json = $this->getJsonDump(); + $checksum = sha1($json, true); + if (! BasketContent::exists($checksum, $this->getConnection())) { + BasketContent::create([ + 'checksum' => $checksum, + 'summary' => $this->getJsonSummary(), + 'content' => $json, + ], $this->getConnection())->store(); + } + + $this->set('content_checksum', $checksum); + $this->set('ts_create', round(microtime(true) * 1000)); + } + + /** + * @param Db $connection + * @param bool $replace + * @throws \Icinga\Exception\NotFoundError + */ + public function restoreTo(Db $connection, $replace = true) + { + static::restoreJson( + $this->getJsonDump(), + $connection, + $replace + ); + } + + /** + * @param Basket $basket + * @param $string + * @return BasketSnapshot + */ + public static function forBasketFromJson(Basket $basket, $string) + { + $snapshot = static::create([ + 'basket_uuid' => $basket->get('uuid') + ]); + $snapshot->objects = []; + foreach ((array) Json::decode($string) as $type => $objects) { + $snapshot->objects[$type] = (array) $objects; + } + + return $snapshot; + } + + public static function restoreJson($string, Db $connection, $replace = true) + { + $snapshot = new static(); + $snapshot->restoreObjects( + Json::decode($string), + $connection, + $replace + ); + } + + /** + * @param $all + * @param Db $connection + * @param bool $replace + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Zend_Db_Adapter_Exception + * @throws \Icinga\Exception\NotFoundError + */ + protected function restoreObjects($all, Db $connection, $replace = true) + { + $db = $connection->getDbAdapter(); + $db->beginTransaction(); + $fieldResolver = new BasketSnapshotFieldResolver($all, $connection); + $this->restoreType($all, 'DataList', $fieldResolver, $connection, $replace); + $this->restoreType($all, 'DatafieldCategory', $fieldResolver, $connection, $replace); + $fieldResolver->storeNewFields(); + foreach ($this->restoreOrder as $typeName) { + $this->restoreType($all, $typeName, $fieldResolver, $connection, $replace); + } + $db->commit(); + } + + /** + * @param $all + * @param $typeName + * @param BasketSnapshotFieldResolver $fieldResolver + * @param Db $connection + * @param $replace + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Zend_Db_Adapter_Exception + */ + public function restoreType( + &$all, + $typeName, + BasketSnapshotFieldResolver $fieldResolver, + Db $connection, + $replace + ) { + if (isset($all->$typeName)) { + $objects = (array) $all->$typeName; + } else { + return; + } + $class = static::getClassForType($typeName); + + $changed = []; + foreach ($objects as $key => $object) { + /** @var DbObject $new */ + $new = $class::import($object, $connection, $replace); + if ($new->hasBeenModified()) { + if ($new instanceof IcingaObject && $new->supportsImports()) { + /** @var ExportInterface $new */ + $changed[$new->getUniqueIdentifier()] = $new; + } else { + $new->store(); + // Linking fields right now, as we're not in $changed + if ($new instanceof IcingaObject) { + $fieldResolver->relinkObjectFields($new, $object); + } + } + } else { + // No modification on the object, still, fields might have + // been changed + if ($new instanceof IcingaObject) { + $fieldResolver->relinkObjectFields($new, $object); + } + } + $allObjects[spl_object_hash($new)] = $object; + } + + /** @var IcingaObject $object */ + foreach ($changed as $object) { + $this->recursivelyStore($object, $changed); + } + foreach ($changed as $key => $new) { + // Store related fields. As objects might have formerly been + // un-stored, let's to it right here + if ($new instanceof IcingaObject) { + $fieldResolver->relinkObjectFields($new, $objects[$key]); + } + } + } + + /** + * @param IcingaObject $object + * @param $list + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function recursivelyStore(IcingaObject $object, &$list) + { + foreach ($object->listImportNames() as $parent) { + if (array_key_exists($parent, $list)) { + $this->recursivelyStore($list[$parent], $list); + } + } + + $object->store(); + } + + /** + * @return BasketContent + * @throws \Icinga\Exception\NotFoundError + */ + protected function getContent() + { + if ($this->content === null) { + $this->content = BasketContent::load($this->get('content_checksum'), $this->getConnection()); + } + + return $this->content; + } + + protected function onDelete() + { + $db = $this->getDb(); + $db->delete( + ['bc' => 'director_basket_content'], + 'NOT EXISTS (SELECT director_basket_checksum WHERE content_checksum = bc.checksum)' + ); + } + + /** + * @return string + * @throws \Icinga\Exception\NotFoundError + */ + public function getJsonSummary() + { + if ($this->hasBeenLoadedFromDb()) { + return $this->getContent()->get('summary'); + } + + return Json::encode($this->getSummary(), JSON_PRETTY_PRINT); + } + + /** + * @return array|mixed + * @throws \Icinga\Exception\NotFoundError + */ + public function getSummary() + { + if ($this->hasBeenLoadedFromDb()) { + return Json::decode($this->getContent()->get('summary')); + } + + $summary = []; + foreach (array_keys($this->objects) as $key) { + $summary[$key] = count($this->objects[$key]); + } + + return $summary; + } + + /** + * @return string + * @throws \Icinga\Exception\NotFoundError + */ + public function getJsonDump() + { + if ($this->hasBeenLoadedFromDb()) { + return $this->getContent()->get('content'); + } + + try { + return JsonString::encode($this->objects, JSON_PRETTY_PRINT); + } catch (JsonEncodeException $e) { + foreach ($this->objects as $type => $objects) { + foreach ($objects as $object) { + try { + JsonString::encode($object); + } catch (JsonEncodeException $singleError) { + $dump = var_export($object, 1); + if (function_exists('iconv')) { + $dump = iconv('UTF-8', 'UTF-8//IGNORE', $dump); + } + throw new JsonEncodeException(sprintf( + 'Failed to encode object ot type "%s": %s, %s', + $type, + $dump, + $singleError->getMessage() + ), $singleError->getCode()); + } + } + } + + throw $e; + } + } + + protected function addAll($typeName) + { + list($class, $filter) = static::getClassAndObjectTypeForType($typeName); + $connection = $this->getConnection(); + assert($connection instanceof Db); + + /** @var IcingaObject $dummy */ + $dummy = $class::create(); + if ($dummy instanceof IcingaObject && $dummy->supportsImports()) { + $db = $this->getDb(); + $select = $db->select()->from($dummy->getTableName()); + if ($filter) { + foreach ($filter as $column => $value) { + $select->where("$column = ?", $value); + } + } elseif (! $dummy->isGroup() + // TODO: this is ugly. + && ! $dummy instanceof IcingaDependency + && ! $dummy instanceof IcingaTimePeriod + ) { + $select->where('object_type = ?', 'template'); + } + $all = $class::loadAll($connection, $select); + } else { + $all = $class::loadAll($connection); + } + $exporter = new Exporter($connection); + foreach ($all as $object) { + $this->objects[$typeName][$object->getUniqueIdentifier()] = $exporter->export($object); + } + } + + protected function addByIdentifiers($typeName, $identifiers) + { + foreach ($identifiers as $identifier) { + $this->addByIdentifier($typeName, $identifier); + } + } + + /** + * @param $typeName + * @param $identifier + * @param Db $connection + * @return ExportInterface|DbObject|null + */ + public static function instanceByIdentifier($typeName, $identifier, Db $connection) + { + $class = static::getClassForType($typeName); + if (substr($class, -13) === 'IcingaService') { + $identifier = [ + 'object_type' => 'template', + 'object_name' => $identifier, + ]; + } + /** @var ExportInterface $object */ + if ($class::exists($identifier, $connection)) { + $object = $class::load($identifier, $connection); + } else { + $object = null; + } + + return $object; + } + + /** + * @param $typeName + * @param $identifier + */ + protected function addByIdentifier($typeName, $identifier) + { + /** @var Db $connection */ + $connection = $this->getConnection(); + $exporter = new Exporter($connection); + $object = static::instanceByIdentifier( + $typeName, + $identifier, + $connection + ); + if ($object !== null) { + $this->objects[$typeName][$identifier] = $exporter->export($object); + } + } +} diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php new file mode 100644 index 0000000..4653255 --- /dev/null +++ b/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php @@ -0,0 +1,226 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\IcingaObject; + +class BasketSnapshotFieldResolver +{ + /** @var BasketSnapshot */ + protected $snapshot; + + /** @var \Icinga\Module\Director\Data\Db\DbConnection */ + protected $targetDb; + + /** @var array|null */ + protected $requiredIds; + + protected $objects; + + /** @var int */ + protected $nextNewId = 1; + + /** @var array|null */ + protected $idMap; + + /** @var DirectorDatafield[]|null */ + protected $targetFields; + + public function __construct($objects, Db $targetDb) + { + $this->objects = $objects; + $this->targetDb = $targetDb; + } + + /** + * @param Db $db + * @return DirectorDatafield[] + * @throws \Icinga\Exception\NotFoundError + */ + public function loadCurrentFields(Db $db) + { + $fields = []; + foreach ($this->getRequiredIds() as $id) { + $fields[$id] = DirectorDatafield::loadWithAutoIncId((int) $id, $db); + } + + return $fields; + } + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function storeNewFields() + { + $this->targetFields = null; // Clear Cache + foreach ($this->getTargetFields() as $id => $field) { + if ($field->hasBeenModified()) { + $field->store(); + $this->idMap[$id] = $field->get('id'); + } + } + } + + /** + * @param IcingaObject $new + * @param $object + * @throws \Icinga\Exception\NotFoundError + * @throws \Zend_Db_Adapter_Exception + */ + public function relinkObjectFields(IcingaObject $new, $object) + { + if (! $new->supportsFields() || ! isset($object->fields)) { + return; + } + $fieldMap = $this->getIdMap(); + + $objectId = (int) $new->get('id'); + $table = $new->getTableName() . '_field'; + $objectKey = $new->getShortTableName() . '_id'; + $existingFields = []; + + $db = $this->targetDb->getDbAdapter(); + + foreach ($db->fetchAll( + $db->select()->from($table)->where("$objectKey = ?", $objectId) + ) as $mapping) { + $existingFields[(int) $mapping->datafield_id] = $mapping; + } + foreach ($object->fields as $field) { + $id = $fieldMap[(int) $field->datafield_id]; + if (isset($existingFields[$id])) { + unset($existingFields[$id]); + } else { + $db->insert($table, [ + $objectKey => $objectId, + 'datafield_id' => $id, + 'is_required' => $field->is_required, + 'var_filter' => $field->var_filter, + ]); + } + } + if (! empty($existingFields)) { + $db->delete( + $table, + $db->quoteInto( + "$objectKey = $objectId AND datafield_id IN (?)", + array_keys($existingFields) + ) + ); + } + } + + /** + * @param object $object + * @throws \Icinga\Exception\NotFoundError + */ + public function tweakTargetIds($object) + { + $forward = $this->getIdMap(); + $map = array_flip($forward); + if (isset($object->fields)) { + foreach ($object->fields as $field) { + $id = $field->datafield_id; + if (isset($map[$id])) { + $field->datafield_id = $map[$id]; + } else { + $field->datafield_id = "(NEW)"; + } + } + } + } + + /** + * @return int + */ + protected function getNextNewId() + { + return $this->nextNewId++; + } + + protected function getRequiredIds() + { + if ($this->requiredIds === null) { + if (isset($this->objects['Datafield'])) { + $this->requiredIds = array_keys($this->objects['Datafield']); + } else { + $ids = []; + foreach ($this->objects as $typeName => $objects) { + foreach ($objects as $key => $object) { + if (isset($object->fields)) { + foreach ($object->fields as $field) { + $ids[$field->datafield_id] = true; + } + } + } + } + + $this->requiredIds = array_keys($ids); + } + } + + return $this->requiredIds; + } + + /** + * @param $type + * @return object[] + */ + protected function getObjectsByType($type) + { + if (isset($this->objects->$type)) { + return (array) $this->objects->$type; + } else { + return []; + } + } + + /** + * @return DirectorDatafield[] + * @throws \Icinga\Exception\NotFoundError + */ + protected function getTargetFields() + { + if ($this->targetFields === null) { + $this->calculateIdMap(); + } + + return $this->targetFields; + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function getIdMap() + { + if ($this->idMap === null) { + $this->calculateIdMap(); + } + + return $this->idMap; + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + protected function calculateIdMap() + { + $this->idMap = []; + $this->targetFields = []; + foreach ($this->getObjectsByType('Datafield') as $id => $object) { + unset($object->category_id); // Fix old baskets + // Hint: import() doesn't store! + $new = DirectorDatafield::import($object, $this->targetDb); + if ($new->hasBeenLoadedFromDb()) { + $newId = (int) $new->get('id'); + } else { + $newId = sprintf('NEW(%s)', $this->getNextNewId()); + } + $this->idMap[$id] = $newId; + $this->targetFields[$id] = $new; + } + } +} diff --git a/library/Director/DirectorObject/Automation/CompareBasketObject.php b/library/Director/DirectorObject/Automation/CompareBasketObject.php new file mode 100644 index 0000000..ef2e9e2 --- /dev/null +++ b/library/Director/DirectorObject/Automation/CompareBasketObject.php @@ -0,0 +1,146 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Core\Json; +use ipl\Html\Error; +use RuntimeException; +use function array_key_exists; +use function is_array; +use function is_object; +use function is_scalar; + +class CompareBasketObject +{ + public static function normalize(&$value) + { + if (is_scalar($value)) { + return; + } + if (is_array($value)) { + foreach ($value as $k => &$v) { + static::normalize($v); + } + unset($v); + } + if (is_object($value)) { + $sorted = (array) $value; + // foreign baskets might not sort as we do: + ksort($sorted); + foreach ($sorted as $k => &$v) { + static::normalize($v); + } + unset($v); + $value = $sorted; + + // foreign baskets might not sort those lists correctly: + if (isset($value->list_name) && isset($value->entries)) { + static::sortListBy('entry_name', $value->entries); + } + if (isset($value->fields)) { + static::sortListBy('datafield_id', $value->fields); + } + } + } + + protected static function sortListBy($key, &$list) + { + usort($list, function ($a, $b) use ($key) { + return $a->$key > $b->$key ? -1 : 1; + }); + } + + public static function equals($a, $b) + { + if (is_scalar($a)) { + return $a === $b; + } + + if ($a === null) { + return $b === null; + } + + // Well... this is annoying :-/ + $a = Json::decode(Json::encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $b = Json::decode(Json::encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + if (is_array($a)) { + // Empty arrays VS empty objects :-( This is a fallback, not needed unless en/decode takes place + if (empty($a) && is_object($b) && (array) $b === []) { + return true; + } + if (! is_array($b)) { + return false; + } + if (array_keys($a) !== array_keys($b)) { + return false; + } + foreach ($a as $k => $v) { + if (array_key_exists($k, $b) && static::equals($b[$k], $v)) { + continue; + } + return false; + } + + return true; + } + + if (is_object($a)) { + // Well... empty arrays VS empty objects :-( + if ($b === [] && (array) $a === []) { + return true; + } + if (! is_object($b)) { + return false; + } + + // Workaround, same as above + if (isset($a->list_name) && isset($a->entries)) { + if (! isset($b->entries)) { + return false; + } + static::sortListBy('entry_name', $a->entries); + static::sortListBy('entry_name', $b->entries); + } + if (isset($a->fields) && isset($b->fields)) { + static::sortListBy('datafield_id', $a->fields); + static::sortListBy('datafield_id', $b->fields); + } + foreach ((array) $a as $k => $v) { + if (property_exists($b, $k) && static::equals($v, $b->$k)) { + continue; + } + if (! property_exists($b, $k)) { + if ($v === null) { + continue; + } + // Deal with two special defaults: + if ($k === 'set_if_format' && $v === 'string') { + continue; + } + if ($k === 'disabled' && $v === false) { + continue; + } + } + return false; + } + foreach ((array) $b as $k => $v) { + if (! property_exists($a, $k)) { + if ($v === null) { + continue; + } + // Once again: + if ($k === 'set_if_format' && $v === 'string') { + continue; + } + if ($k === 'disabled' && $v === false) { + continue; + } + return false; + } + } + return true; + } + + throw new RuntimeException("Cannot compare " . Error::getPhpTypeName($a)); + } +} diff --git a/library/Director/DirectorObject/Automation/ExportInterface.php b/library/Director/DirectorObject/Automation/ExportInterface.php new file mode 100644 index 0000000..275dfed --- /dev/null +++ b/library/Director/DirectorObject/Automation/ExportInterface.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Db; + +interface ExportInterface +{ + /** + * @deprecated + * @return \stdClass + */ + public function export(); + + public static function import($plain, Db $db, $replace = false); + + // TODO: + // public function getXyzChecksum(); + public function getUniqueIdentifier(); +} diff --git a/library/Director/DirectorObject/Automation/ImportExport.php b/library/Director/DirectorObject/Automation/ImportExport.php new file mode 100644 index 0000000..a5e72fa --- /dev/null +++ b/library/Director/DirectorObject/Automation/ImportExport.php @@ -0,0 +1,149 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Automation; + +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\DirectorDatalist; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\IcingaHostGroup; +use Icinga\Module\Director\Objects\IcingaServiceGroup; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\Objects\IcingaTemplateChoiceHost; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\SyncRule; + +class ImportExport +{ + /** @var Db */ + protected $connection; + + /** @var Exporter */ + protected $exporter; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->exporter = new Exporter($connection); + } + + public function serializeAllServiceSets() + { + $res = []; + foreach (IcingaServiceSet::loadAll($this->connection) as $object) { + if ($object->get('host_id')) { + continue; + } + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllHostTemplateChoices() + { + $res = []; + foreach (IcingaTemplateChoiceHost::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllHostGroups() + { + $res = []; + foreach (IcingaHostGroup::loadAll($this->connection) as $object) { + $res[] = $object->toPlainObject(); + } + + return $res; + } + + public function serializeAllServiceGroups() + { + $res = []; + foreach (IcingaServiceGroup::loadAll($this->connection) as $object) { + $res[] = $object->toPlainObject(); + } + + return $res; + } + + public function serializeAllDataFields() + { + $res = []; + foreach (DirectorDatafield::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllDataLists() + { + $res = []; + foreach (DirectorDatalist::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllJobs() + { + $res = []; + foreach (DirectorJob::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllImportSources() + { + $res = []; + foreach (ImportSource::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function serializeAllSyncRules() + { + $res = []; + foreach (SyncRule::loadAll($this->connection) as $object) { + $res[] = $this->exporter->export($object); + } + + return $res; + } + + public function unserializeImportSources($objects) + { + $count = 0; + $this->connection->runFailSafeTransaction(function () use ($objects, &$count) { + foreach ($objects as $object) { + ImportSource::import($object, $this->connection)->store(); + $count++; + } + }); + + return $count; + } + + public function unserializeSyncRules($objects) + { + $count = 0; + $this->connection->runFailSafeTransaction(function () use ($objects, &$count) { + foreach ($objects as $object) { + SyncRule::import($object, $this->connection)->store(); + } + $count++; + }); + + return $count; + } +} diff --git a/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php b/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php new file mode 100644 index 0000000..abda497 --- /dev/null +++ b/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php @@ -0,0 +1,109 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\HostApplyMatches; +use Icinga\Module\Director\Objects\IcingaHost; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +/** + * A Service Apply Rule matching this Host, generating a Service with the given + * name + */ +class AppliedServiceInfo implements ServiceInfo +{ + /** @var string */ + protected $hostName; + + /** @var string */ + protected $serviceName; + + /** @var int */ + protected $serviceApplyRuleId; + + /** @var UuidInterface */ + protected $uuid; + + public function __construct($hostName, $serviceName, $serviceApplyRuleId, UuidInterface $uuid) + { + $this->hostName = $hostName; + $this->serviceName= $serviceName; + $this->serviceApplyRuleId = $serviceApplyRuleId; + $this->uuid = $uuid; + } + + public static function find(IcingaHost $host, $serviceName) + { + $matcher = HostApplyMatches::prepare($host); + $connection = $host->getConnection(); + foreach (static::fetchApplyRulesByServiceName($connection, $serviceName) as $rule) { + if ($matcher->matchesFilter($rule->filter)) { + return new static($host->getObjectName(), $serviceName, (int) $rule->id, $rule->uuid); + } + } + + return null; + } + + public function getHostName() + { + return $this->hostName; + } + + /** + * @return int + */ + public function getServiceApplyRuleId() + { + return $this->serviceApplyRuleId; + } + + public function getName() + { + return $this->serviceName; + } + + public function getUuid() + { + return $this->uuid; + } + + public function getUrl() + { + return Url::fromPath('director/host/appliedservice', [ + 'name' => $this->hostName, + 'service_id' => $this->serviceApplyRuleId, + ]); + } + + public function requiresOverrides() + { + return true; + } + + protected static function fetchApplyRulesByServiceName(Db $connection, $serviceName) + { + $db = $connection->getDbAdapter(); + $query = $db->select() + ->from(['s' => 'icinga_service'], [ + 'id' => 's.id', + 'uuid' => 's.uuid', + 'name' => 's.object_name', + 'assign_filter' => 's.assign_filter', + ]) + ->where('object_name = ?', $serviceName) + ->where('object_type = ? AND assign_filter IS NOT NULL', 'apply'); + + $allRules = $db->fetchAll($query); + foreach ($allRules as $rule) { + $rule->uuid = Uuid::fromBytes(Db\DbUtil::binaryResult($rule->uuid)); + $rule->filter = Filter::fromQueryString($rule->assign_filter); + } + + return $allRules; + } +} diff --git a/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php b/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php new file mode 100644 index 0000000..b5785d5 --- /dev/null +++ b/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php @@ -0,0 +1,127 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\HostApplyMatches; +use Icinga\Module\Director\Objects\IcingaHost; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +/** + * A Service that makes part of a Service Set Apply Rule matching this Host, + * generating a Service with the given name + */ +class AppliedServiceSetServiceInfo implements ServiceInfo +{ + /** @var string */ + protected $hostName; + + /** @var string */ + protected $serviceName; + + /** @var string */ + protected $serviceSetName; + + /** @var UuidInterface */ + protected $uuid; + + public function __construct($hostName, $serviceName, $serviceSetName, UuidInterface $uuid) + { + $this->hostName = $hostName; + $this->serviceName = $serviceName; + $this->serviceSetName = $serviceSetName; + $this->uuid = $uuid; + } + + public static function find(IcingaHost $host, $serviceName) + { + $matcher = HostApplyMatches::prepare($host); + $connection = $host->getConnection(); + foreach (static::fetchServiceSetApplyRulesByServiceName($connection, $host->get('id'), $serviceName) as $rule) { + if ($matcher->matchesFilter($rule->filter)) { + return new static( + $host->getObjectName(), + $serviceName, + $rule->service_set_name, + $rule->uuid + ); + } + } + + return null; + } + + public function getHostName() + { + return $this->hostName; + } + + public function getUuid() + { + return $this->uuid; + } + + /** + * @return string + */ + public function getServiceSetName() + { + return $this->serviceSetName; + } + + public function getName() + { + return $this->serviceName; + } + + public function getUrl() + { + return Url::fromPath('director/host/servicesetservice', [ + 'name' => $this->hostName, + 'service' => $this->serviceName, + 'set' => $this->serviceSetName, + ]); + } + + public function requiresOverrides() + { + return true; + } + + protected static function fetchServiceSetApplyRulesByServiceName(Db $connection, $hostId, $serviceName) + { + $db = $connection->getDbAdapter(); + $query = $db->select() + ->from(['s' => 'icinga_service'], [ + 'id' => 's.id', + 'uuid' => 'ss.uuid', + 'name' => 's.object_name', + 'assign_filter' => 'ss.assign_filter', + 'service_set_name' => 'ss.object_name', + ]) + ->join( + ['ss' => 'icinga_service_set'], + 's.service_set_id = ss.id', + [] + ) + ->where('s.object_name = ?', $serviceName) + ->where('ss.assign_filter IS NOT NULL') + ->where( // Ignore deactivated Services: + 'NOT EXISTS (SELECT 1 FROM icinga_host_service_blacklist hsb' + . ' WHERE hsb.host_id = ? AND hsb.service_id = s.id)', + (int) $hostId + ); + ; + + $allRules = $db->fetchAll($query); + foreach ($allRules as $rule) { + $rule->uuid = Uuid::fromBytes(Db\DbUtil::binaryResult($rule->uuid)); + $rule->filter = Filter::fromQueryString($rule->assign_filter); + } + + return $allRules; + } +} diff --git a/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php b/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php new file mode 100644 index 0000000..875d5fb --- /dev/null +++ b/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php @@ -0,0 +1,94 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use Ramsey\Uuid\UuidInterface; + +/** + * A Service attached to a parent Service Template. This is a shortcut for + * 'assign where "Template Name" in templates' + */ +class InheritedServiceInfo implements ServiceInfo +{ + /** @var string */ + protected $hostName; + + /** @var string */ + protected $hostTemplateName; + + /** @var string */ + protected $serviceName; + + /** @var UuidInterface */ + protected $uuid; + + public function __construct($hostName, $hostTemplateName, $serviceName, UuidInterface $uuid) + { + $this->hostName = $hostName; + $this->hostTemplateName = $hostTemplateName; + $this->serviceName= $serviceName; + $this->uuid = $uuid; + } + + public static function find(IcingaHost $host, $serviceName) + { + $db = $host->getConnection(); + foreach (IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true) as $parent) { + $key = [ + 'host_id' => $parent->get('id'), + 'object_name' => $serviceName + ]; + if (IcingaService::exists($key, $db)) { + return new static( + $host->getObjectName(), + $parent->getObjectName(), + $serviceName, + IcingaService::load($key, $db)->getUniqueId() + ); + } + } + + return false; + } + + public function getHostName() + { + return $this->hostName; + } + + public function getUuid() + { + return $this->uuid; + } + + /** + * @return string + */ + public function getHostTemplateName() + { + return $this->hostTemplateName; + } + + public function getName() + { + return $this->serviceName; + } + + public function getUrl() + { + return Url::fromPath('director/host/inheritedservice', [ + 'name' => $this->hostName, + 'service' => $this->serviceName, + 'inheritedFrom' => $this->hostTemplateName + ]); + } + + public function requiresOverrides() + { + return true; + } +} diff --git a/library/Director/DirectorObject/Lookup/ServiceFinder.php b/library/Director/DirectorObject/Lookup/ServiceFinder.php new file mode 100644 index 0000000..fb8d74c --- /dev/null +++ b/library/Director/DirectorObject/Lookup/ServiceFinder.php @@ -0,0 +1,79 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Objects\HostApplyMatches; +use Icinga\Module\Director\Objects\IcingaHost; +use RuntimeException; + +class ServiceFinder +{ + /** @var IcingaHost */ + protected $host; + + /** @var ?Auth */ + protected $auth; + + /** @var IcingaHost[] */ + protected $parents; + + /** @var HostApplyMatches */ + protected $applyMatcher; + + /** @var \Icinga\Module\Director\Db */ + protected $db; + + public function __construct(IcingaHost $host, Auth $auth = null) + { + $this->host = $host; + $this->auth = $auth; + $this->db = $host->getConnection(); + } + + public static function find(IcingaHost $host, $serviceName) + { + foreach ([ + SingleServiceInfo::class, + InheritedServiceInfo::class, + ServiceSetServiceInfo::class, + AppliedServiceInfo::class, + AppliedServiceSetServiceInfo::class, + ] as $class) { + /** @var ServiceInfo $class */ + if ($info = $class::find($host, $serviceName)) { + return $info; + } + } + + return false; + } + + /** + * @param $serviceName + * @return Url + */ + public function getRedirectionUrl($serviceName) + { + if ($this->auth === null) { + throw new RuntimeException('Auth is required for ServiceFinder when dealing when asking for URLs'); + } + if ($this->auth->hasPermission('director/host')) { + if ($info = $this::find($this->host, $serviceName)) { + return $info->getUrl(); + } + } + if ($this->auth->hasPermission('director/monitoring/services-ro')) { + return Url::fromPath('director/host/servicesro', [ + 'name' => $this->host->getObjectName(), + 'service' => $serviceName + ]); + } + + return Url::fromPath('director/host/invalidservice', [ + 'name' => $this->host->getObjectName(), + 'service' => $serviceName, + ]); + } +} diff --git a/library/Director/DirectorObject/Lookup/ServiceInfo.php b/library/Director/DirectorObject/Lookup/ServiceInfo.php new file mode 100644 index 0000000..3c8c51b --- /dev/null +++ b/library/Director/DirectorObject/Lookup/ServiceInfo.php @@ -0,0 +1,46 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\IcingaHost; +use Ramsey\Uuid\UuidInterface; + +interface ServiceInfo +{ + /** + * The final Service name + * + * @return string + */ + public function getName(); + + /** + * The host the final (rendered, processed) Service belongs to + * + * @return string + */ + public function getHostName(); + + /** + * @return Url + */ + public function getUrl(); + + /** + * @return UuidInterface + */ + public function getUuid(); + + /** + * @return bool + */ + public function requiresOverrides(); + + /** + * @param IcingaHost $host + * @param $serviceName + * @return ServiceInfo|false + */ + public static function find(IcingaHost $host, $serviceName); +} diff --git a/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php b/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php new file mode 100644 index 0000000..a980da8 --- /dev/null +++ b/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php @@ -0,0 +1,121 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +/** + * A service belonging to a Service Set, attached either directly to the given + * Host or to one of it's inherited Host Templates + */ +class ServiceSetServiceInfo implements ServiceInfo +{ + /** @var string */ + protected $hostName; + + /** @var string */ + protected $serviceName; + + /** @var string */ + protected $serviceSetName; + + /** @var UuidInterface */ + protected $uuid; + + public function __construct($hostName, $serviceName, $serviceSetName, UuidInterface $uuid) + { + $this->hostName = $hostName; + $this->serviceName = $serviceName; + $this->serviceSetName = $serviceSetName; + $this->uuid = $uuid; + } + + public static function find(IcingaHost $host, $serviceName) + { + $ids = [$host->get('id')]; + + foreach (IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true) as $parent) { + $ids[] = $parent->get('id'); + } + + $db = $host->getConnection()->getDbAdapter(); + $query = $db->select() + ->from( + ['s' => 'icinga_service'], + [ + 'service_set_name' => 'ss.object_name', + 'uuid' => 's.uuid', + ] + )->join( + ['ss' => 'icinga_service_set'], + 's.service_set_id = ss.id', + [] + )->join( + ['hsi' => 'icinga_service_set_inheritance'], + 'hsi.parent_service_set_id = ss.id', + [] + )->join( + ['hs' => 'icinga_service_set'], + 'hs.id = hsi.service_set_id', + [] + )->where('hs.host_id IN (?)', $ids) + ->where('s.object_name = ?', $serviceName) + ->where( // Ignore deactivated Services: + 'NOT EXISTS (SELECT 1 FROM icinga_host_service_blacklist hsb' + . ' WHERE hsb.host_id = ? AND hsb.service_id = s.id)', + (int) $host->get('id') + ); + + if ($row = $db->fetchRow($query)) { + return new static( + $host->getObjectName(), + $serviceName, + $row->service_set_name, + Uuid::fromBytes($row->uuid) + ); + } + + return null; + } + + public function getHostName() + { + return $this->hostName; + } + + public function getName() + { + return $this->serviceName; + } + + public function getUuid() + { + return $this->uuid; + } + + /** + * @return string + */ + public function getServiceSetName() + { + return $this->serviceSetName; + } + + public function getUrl() + { + return Url::fromPath('director/host/servicesetservice', [ + 'name' => $this->hostName, + 'service' => $this->serviceName, + 'set' => $this->serviceSetName, + ]); + } + + public function requiresOverrides() + { + return true; + } +} diff --git a/library/Director/DirectorObject/Lookup/SingleServiceInfo.php b/library/Director/DirectorObject/Lookup/SingleServiceInfo.php new file mode 100644 index 0000000..af54fc7 --- /dev/null +++ b/library/Director/DirectorObject/Lookup/SingleServiceInfo.php @@ -0,0 +1,83 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject\Lookup; + +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaService; +use Ramsey\Uuid\UuidInterface; + +/** + * A single service, directly attached to a Host Object. Overrides might + * still be used when use_var_overrides is true. + */ +class SingleServiceInfo implements ServiceInfo +{ + /** @var string */ + protected $hostName; + + /** @var string */ + protected $serviceName; + + /** @var bool */ + protected $useOverrides; + + /** @var UuidInterface */ + protected $uuid; + + public function __construct($hostName, $serviceName, UuidInterface $uuid, $useOverrides) + { + $this->hostName = $hostName; + $this->serviceName = $serviceName; + $this->useOverrides = $useOverrides; + $this->uuid = $uuid; + } + + public static function find(IcingaHost $host, $serviceName) + { + $keyParams = [ + 'host_id' => $host->get('id'), + 'object_name' => $serviceName + ]; + $connection = $host->getConnection(); + if (IcingaService::exists($keyParams, $connection)) { + $service = IcingaService::load($keyParams, $connection); + $useOverrides = $service->getResolvedVar('use_var_overrides') === 'y'; + + return new static($host->getObjectName(), $serviceName, $service->getUniqueId(), $useOverrides); + } + + return false; + } + + public function getHostName() + { + return $this->hostName; + } + + public function getName() + { + return $this->serviceName; + } + + /** + * @return UuidInterface + */ + public function getUuid() + { + return $this->uuid; + } + + public function getUrl() + { + return Url::fromPath('director/service/edit', [ + 'host' => $this->hostName, + 'name' => $this->serviceName, + ]); + } + + public function requiresOverrides() + { + return $this->useOverrides; + } +} diff --git a/library/Director/DirectorObject/ObjectPurgeHelper.php b/library/Director/DirectorObject/ObjectPurgeHelper.php new file mode 100644 index 0000000..a043965 --- /dev/null +++ b/library/Director/DirectorObject/ObjectPurgeHelper.php @@ -0,0 +1,149 @@ +<?php + +namespace Icinga\Module\Director\DirectorObject; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use InvalidArgumentException; + +class ObjectPurgeHelper +{ + protected $db; + + protected $force = false; + + public function __construct(Db $db) + { + $this->db = $db; + } + + public function force($force = true) + { + $this->force = $force; + return $this; + } + + public function purge(array $keep, $class, $objectType = null) + { + if (empty($keep) && ! $this->force) { + throw new InvalidArgumentException('I will NOT purge all object unless being forced to do so'); + } + $db = $this->db->getDbAdapter(); + /** @var IcingaObject $class cheating, it's a class name, not an object */ + $dummy = $class::create(); + assert($dummy instanceof IcingaObject); + $keyCols = (array) $dummy->getKeyName(); + if ($objectType !== null) { + $keyCols[] = 'object_type'; + } + + $keepKeys = []; + foreach ($keep as $object) { + if ($object instanceof \stdClass) { + $properties = (array) $object; + // TODO: this is object-specific and to be found in the ::import() function! + unset($properties['fields']); + $object = $class::fromPlainObject($properties); + } elseif (\get_class($object) !== $class) { + throw new InvalidArgumentException( + 'Can keep only matching objects, expected "%s", got "%s', + $class, + \get_class($keep) + ); + } + $key = []; + foreach ($keyCols as $col) { + $key[$col] = $object->get($col); + } + $keepKeys[$this->makeRowKey($key)] = true; + } + + $query = $db->select()->from(['o' => $dummy->getTableName()], $keyCols); + if ($objectType !== null) { + $query->where('object_type = ?', $objectType); + } + $allExisting = []; + foreach ($db->fetchAll($query) as $row) { + $allExisting[$this->makeRowKey($row)] = $row; + } + $remove = []; + foreach ($allExisting as $key => $keyProperties) { + if (! isset($keepKeys[$key])) { + $remove[] = $keyProperties; + } + } + $db->beginTransaction(); + foreach ($remove as $keyProperties) { + $keyColumn = $class::getKeyColumnName(); + if (is_array($keyColumn)) { + $object = $class::load((array) $keyProperties, $this->db); + } else { + $object = $class::load($keyProperties->$keyColumn, $this->db); + } + $object->delete(); + } + $db->commit(); + } + + public static function listObjectTypesAvailableForPurge() + { + return [ + 'Basket', + 'Command', + 'CommandTemplate', + 'Dependency', + 'DirectorJob', + 'ExternalCommand', + 'HostGroup', + 'HostTemplate', + 'IcingaTemplateChoiceHost', + 'IcingaTemplateChoiceService', + 'ImportSource', + 'Notification', + 'NotificationTemplate', + 'ServiceGroup', + 'ServiceSet', + 'ServiceTemplate', + 'SyncRule', + 'TimePeriod', + ]; + } + + public static function objectTypeIsEligibleForPurge($type) + { + return in_array($type, static::listObjectTypesAvailableForPurge(), true); + } + + public static function assertObjectTypesAreEligibleForPurge($types) + { + $invalid = []; + foreach ($types as $type) { + if (! static::objectTypeIsEligibleForPurge($type)) { + $invalid[] = $type; + } + } + + if (empty($invalid)) { + return; + } + + if (count($invalid) === 1) { + $message = sprintf('"%s" is not eligible for purge', $invalid[0]); + } else { + $message = 'The following types are not eligible for purge: ' + . implode(', ', $invalid); + } + + throw new InvalidArgumentException( + "$message. Valid types: " + . implode(', ', static::listObjectTypesAvailableForPurge()) + ); + } + + protected function makeRowKey($row) + { + $row = (array) $row; + ksort($row); + return json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/library/Director/Exception/DuplicateKeyException.php b/library/Director/Exception/DuplicateKeyException.php new file mode 100644 index 0000000..a9cba65 --- /dev/null +++ b/library/Director/Exception/DuplicateKeyException.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Director\Exception; + +use Icinga\Exception\IcingaException; + +class DuplicateKeyException extends IcingaException +{ +} diff --git a/library/Director/Exception/JsonEncodeException.php b/library/Director/Exception/JsonEncodeException.php new file mode 100644 index 0000000..7db2f77 --- /dev/null +++ b/library/Director/Exception/JsonEncodeException.php @@ -0,0 +1,7 @@ +<?php + +namespace Icinga\Module\Director\Exception; + +class JsonEncodeException extends JsonException +{ +} diff --git a/library/Director/Exception/JsonException.php b/library/Director/Exception/JsonException.php new file mode 100644 index 0000000..dad848d --- /dev/null +++ b/library/Director/Exception/JsonException.php @@ -0,0 +1,55 @@ +<?php + +namespace Icinga\Module\Director\Exception; + +use Icinga\Exception\IcingaException; + +class JsonException extends IcingaException +{ + public static function forLastJsonError($msg = null) + { + if ($msg === null) { + return new static(static::getJsonErrorMessage(\json_last_error())); + } else { + return new static($msg . ': ' . static::getJsonErrorMessage(\json_last_error())); + } + } + + public static function getJsonErrorMessage($code) + { + $map = [ + JSON_ERROR_DEPTH => 'The maximum stack depth has been exceeded', + JSON_ERROR_CTRL_CHAR => 'Control character error, possibly incorrectly encoded', + JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', + JSON_ERROR_SYNTAX => 'JSON Syntax error', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded' + ]; + if (\array_key_exists($code, $map)) { + return $map[$code]; + } + + if (PHP_VERSION_ID >= 50500) { + $map = [ + JSON_ERROR_RECURSION => 'One or more recursive references in the value to be encoded', + JSON_ERROR_INF_OR_NAN => 'One or more NAN or INF values in the value to be encoded', + JSON_ERROR_UNSUPPORTED_TYPE => 'A value of a type that cannot be encoded was given', + ]; + if (\array_key_exists($code, $map)) { + return $map[$code]; + } + } + + if (PHP_VERSION_ID >= 70000) { + $map = [ + JSON_ERROR_INVALID_PROPERTY_NAME => 'A property name that cannot be encoded was given', + JSON_ERROR_UTF16 => 'Malformed UTF-16 characters, possibly incorrectly encoded', + ]; + + if (\array_key_exists($code, $map)) { + return $map[$code]; + } + } + + return 'An error occured when parsing a JSON string'; + } +} diff --git a/library/Director/Exception/NestingError.php b/library/Director/Exception/NestingError.php new file mode 100644 index 0000000..bc191aa --- /dev/null +++ b/library/Director/Exception/NestingError.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Director\Exception; + +use Icinga\Exception\IcingaException; + +class NestingError extends IcingaException +{ +} diff --git a/library/Director/Field/FieldSpec.php b/library/Director/Field/FieldSpec.php new file mode 100644 index 0000000..29baa0b --- /dev/null +++ b/library/Director/Field/FieldSpec.php @@ -0,0 +1,206 @@ +<?php + + +namespace Icinga\Module\Director\Field; + +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\IcingaObject; + +class FieldSpec +{ + /** @var string */ + protected $varName; + + /** @var string */ + protected $category; + + /** @var string */ + protected $caption; + + /** @var boolean */ + protected $isRequired = false; + + /** @var string */ + protected $description; + + /** @var string */ + protected $dataType; + + /** @var string */ + protected $varFilter; + + /** @var string */ + protected $format = "string"; + + /** + * FieldSpec constructor. + * @param $dataType + * @param $varName + * @param $caption + */ + public function __construct($dataType, $varName, $caption) + { + $this->dataType = $dataType; + $this->varName = $varName; + $this->caption = $caption; + } + + public function toDataField(IcingaObject $object) + { + return DirectorDatafield::create([ + 'varname' => $this->getVarName(), + 'category' => $this->getCategory(), + 'caption' => $this->getCaption(), + 'description' => $this->getDescription(), + 'datatype' => $this->getDataType(), + 'format' => $this->getFormat(), + 'var_filter' => $this->getVarFilter(), + 'icinga_type' => $object->getShortTableName(), + 'object_id' => $object->get('id'), + ]); + } + + /** + * @return string + */ + public function getVarName() + { + return $this->varName; + } + + /** + * @param string $varName + * @return FieldSpec + */ + public function setVarName($varName) + { + $this->varName = $varName; + return $this; + } + + /** + * @return string + */ + public function getCaption() + { + return $this->caption; + } + + /** + * @param string $caption + * @return FieldSpec + */ + public function setCaption($caption) + { + $this->caption = $caption; + return $this; + } + + /** + * @return bool + */ + public function isRequired() + { + return $this->isRequired; + } + + /** + * @param bool $isRequired + * @return FieldSpec + */ + public function setIsRequired($isRequired) + { + $this->isRequired = $isRequired; + return $this; + } + + /** + * @return string + */ + public function getDescription() + { + return $this->description; + } + + /** + * @param string $description + * @return FieldSpec + */ + public function setDescription($description) + { + $this->description = $description; + return $this; + } + + /** + * @return string + */ + public function getDataType() + { + return $this->dataType; + } + + /** + * @param string $dataType + * @return FieldSpec + */ + public function setDataType($dataType) + { + $this->dataType = $dataType; + return $this; + } + + /** + * @return string + */ + public function getVarFilter() + { + return $this->varFilter; + } + + /** + * @param string $varFilter + * @return FieldSpec + */ + public function setVarFilter($varFilter) + { + $this->varFilter = $varFilter; + return $this; + } + + /** + * @return string + */ + public function getFormat() + { + return $this->format; + } + + /** + * @param string $format + * @return FieldSpec + */ + public function setFormat($format) + { + $this->format = $format; + return $this; + } + + /** + * @return string + */ + public function getCategory() + { + return $this->category; + } + + /** + * @param string $category + * @return FieldSpec + */ + public function setCategory($category) + { + $this->category = $category; + return $this; + } +} diff --git a/library/Director/Health.php b/library/Director/Health.php new file mode 100644 index 0000000..0d85d18 --- /dev/null +++ b/library/Director/Health.php @@ -0,0 +1,285 @@ +<?php + +namespace Icinga\Module\Director; + +use Icinga\Application\Config; +use Icinga\Date\DateFormatter; +use Icinga\Module\Director\CheckPlugin\Check; +use Icinga\Module\Director\CheckPlugin\CheckResults; +use Icinga\Module\Director\Db\Migrations; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\SyncRule; +use Exception; + +class Health +{ + /** @var Db */ + protected $connection; + + /** @var string */ + protected $dbResourceName; + + protected $checks = [ + 'config' => 'checkConfig', + 'sync' => 'checkSyncRules', + 'import' => 'checkImportSources', + 'jobs' => 'checkDirectorJobs', + 'deployment' => 'checkDeployments', + ]; + + public function setDbResourceName($name) + { + $this->dbResourceName = $name; + + return $this; + } + + public function getCheck($name) + { + if (array_key_exists($name, $this->checks)) { + $func = $this->checks[$name]; + $check = $this->$func(); + } else { + $check = new CheckResults('Invalid Parameter'); + $check->fail("There is no check named '$name'"); + } + + return $check; + } + + public function getAllChecks() + { + /** @var CheckResults[] $checks */ + $checks = [$this->checkConfig()]; + + if ($checks[0]->hasErrors()) { + return $checks; + } + + $checks[] = $this->checkDeployments(); + $checks[] = $this->checkImportSources(); + $checks[] = $this->checkSyncRules(); + $checks[] = $this->checkDirectorJobs(); + + return $checks; + } + + protected function hasDeploymentEndpoint() + { + try { + return $this->connection->hasDeploymentEndpoint(); + } catch (Exception $e) { + return false; + } + } + + public function hasResourceConfig() + { + return $this->getDbResourceName() !== null; + } + + protected function getDbResourceName() + { + if ($this->dbResourceName === null) { + $this->dbResourceName = Config::module('director')->get('db', 'resource'); + } + + return $this->dbResourceName; + } + + protected function getConnection() + { + if ($this->connection === null) { + $this->connection = Db::fromResourceName($this->getDbResourceName()); + } + + return $this->connection; + } + + public function checkConfig() + { + $check = new Check('Director configuration'); + $name = $this->getDbResourceName(); + if ($name) { + $check->succeed("Database resource '$name' has been specified"); + } else { + return $check->fail('No database resource has been specified'); + } + + try { + $db = $this->getConnection(); + } catch (Exception $e) { + return $check->fail($e); + } + + $migrations = new Migrations($db); + $check->assertTrue( + [$migrations, 'hasSchema'], + 'Make sure the DB schema exists' + ); + + if ($check->hasProblems()) { + return $check; + } + + $check->call(function () use ($check, $migrations) { + $count = $migrations->countPendingMigrations(); + + if ($count === 0) { + $check->succeed('There are no pending schema migrations'); + } elseif ($count === 1) { + $check->warn('There is a pending schema migration'); + } else { + $check->warn(sprintf( + 'There are %s pending schema migrations', + $count + )); + } + }); + + return $check; + } + + public function checkSyncRules() + { + $check = new CheckResults('Sync Rules'); + $rules = SyncRule::loadAll($this->getConnection(), null, 'rule_name'); + if (empty($rules)) { + $check->succeed('No Sync Rules have been defined'); + return $check; + } + ksort($rules); + + foreach ($rules as $rule) { + $state = $rule->get('sync_state'); + $name = $rule->get('rule_name'); + if ($state === 'failing') { + $message = $rule->get('last_error_message'); + $check->fail("'$name' is failing: $message"); + } elseif ($state === 'pending-changes') { + $check->succeed("'$name' is fine, but there are pending changes"); + } elseif ($state === 'in-sync') { + $check->succeed("'$name' is in sync"); + } else { + $check->fail("'$name' has never been checked", 'UNKNOWN'); + } + } + + return $check; + } + + public function checkImportSources() + { + $check = new CheckResults('Import Sources'); + $sources = ImportSource::loadAll($this->getConnection(), null, 'source_name'); + if (empty($sources)) { + $check->succeed('No Import Sources have been defined'); + return $check; + } + + ksort($sources); + foreach ($sources as $src) { + $state = $src->get('import_state'); + $name = $src->get('source_name'); + if ($state === 'failing') { + $message = $src->get('last_error_message'); + $check->fail("'$name' is failing: $message"); + } elseif ($state === 'pending-changes') { + $check->succeed("'$name' is fine, but there are pending changes"); + } elseif ($state === 'in-sync') { + $check->succeed("'$name' is in sync"); + } else { + $check->fail("'$name' has never been checked", 'UNKNOWN'); + } + } + + return $check; + } + + public function checkDirectorJobs() + { + $check = new CheckResults('Director Jobs'); + $jobs = DirectorJob::loadAll($this->getConnection(), null, 'job_name'); + if (empty($jobs)) { + $check->succeed('No Jobs have been defined'); + return $check; + } + ksort($jobs); + + foreach ($jobs as $job) { + $name = $job->get('job_name'); + if ($job->hasBeenDisabled()) { + $check->succeed("'$name' has been disabled"); + } elseif ($job->lastAttemptFailed()) { + $message = $job->get('last_error_message'); + $check->fail("Last attempt for '$name' failed: $message"); + } elseif ($job->isOverdue()) { + $check->fail("'$name' is overdue"); + } elseif ($job->shouldRun()) { + $check->succeed("'$name' is fine, but should run now"); + } else { + $check->succeed("'$name' is fine"); + } + } + + return $check; + } + + public function checkDeployments() + { + $check = new Check('Director Deployments'); + + $db = $this->getConnection(); + + $check->call(function () use ($check, $db) { + $check->succeed(sprintf( + "Deployment endpoint is '%s'", + $db->getDeploymentEndpointName() + )); + })->call(function () use ($check, $db) { + $count = $db->countActivitiesSinceLastDeployedConfig(); + + if ($count === 1) { + $check->succeed('There is a single un-deployed change'); + } else { + $check->succeed(sprintf( + 'There are %d un-deployed changes', + $count + )); + } + }); + + if (! DirectorDeploymentLog::hasDeployments($db)) { + $check->warn('Configuration has never been deployed'); + return $check; + } + + $latest = DirectorDeploymentLog::loadLatest($db); + + $ts = $latest->getDeploymentTimestamp(); + $time = DateFormatter::timeAgo($ts); + if ($latest->succeeded()) { + $check->succeed("The last Deployment was successful $time"); + } elseif ($latest->isPending()) { + if ($ts + 180 < time()) { + $check->warn("The last Deployment started $time and is still pending"); + } else { + $check->succeed("The last Deployment started $time and is still pending"); + } + } else { + $check->fail("The last Deployment failed $time"); + } + + return $check; + } + + public function __destruct() + { + if ($this->connection !== null) { + // We created our own connection, so let's tear it down + $this->connection->getDbAdapter()->closeConnection(); + } + } +} diff --git a/library/Director/Hook/BranchSupportHook.php b/library/Director/Hook/BranchSupportHook.php new file mode 100644 index 0000000..6615cbe --- /dev/null +++ b/library/Director/Hook/BranchSupportHook.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Dashboard\Dashlet\Dashlet; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchSTore; +use Icinga\Web\Request; +use ipl\Html\ValidHtml; + +abstract class BranchSupportHook +{ + /** + * @param Request $request + * @param BranchSTore $store + * @param Auth $auth + * @return Branch + */ + abstract public function getBranchForRequest(Request $request, BranchStore $store, Auth $auth); + + /** + * @param Branch $branch + * @param Auth $auth + * @param ?string $label + * @return ?ValidHtml + */ + abstract public function linkToBranch(Branch $branch, Auth $auth, $label = null); + + /** + * @param Db $db + * @return Dashlet[] + */ + public function loadDashlets(Db $db) + { + return []; + } +} diff --git a/library/Director/Hook/DataTypeHook.php b/library/Director/Hook/DataTypeHook.php new file mode 100644 index 0000000..1eb58d6 --- /dev/null +++ b/library/Director/Hook/DataTypeHook.php @@ -0,0 +1,59 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; + +abstract class DataTypeHook +{ + protected $settings = array(); + + public function getName() + { + $parts = explode('\\', get_class($this)); + $class = preg_replace('/DataType/', '', array_pop($parts)); + + if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') { + $module = array_shift($parts); + if ($module !== 'Director') { + return sprintf('%s (%s)', $class, $module); + } + } + + return $class; + } + + public static function getFormat() + { + return 'string'; + } + + /** + * @param $name + * @param QuickForm|DirectorObjectForm $form + * + * @return \Zend_Form_Element + */ + abstract public function getFormElement($name, QuickForm $form); + + public static function addSettingsFormFields(QuickForm $form) + { + return $form; + } + + public function setSettings($settings) + { + $this->settings = $settings; + return $this; + } + + public function getSetting($name, $default = null) + { + if (array_key_exists($name, $this->settings)) { + return $this->settings[$name]; + } else { + return $default; + } + } +} diff --git a/library/Director/Hook/DeploymentHook.php b/library/Director/Hook/DeploymentHook.php new file mode 100644 index 0000000..c8a834b --- /dev/null +++ b/library/Director/Hook/DeploymentHook.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Objects\DirectorDeploymentLog; + +abstract class DeploymentHook +{ + /** + * Please override this method if you want to change the behaviour + * of the deploy (stop it by throwing an exception for some reason) + * + * @param DirectorDeploymentLog $deployment + */ + public function beforeDeploy(DirectorDeploymentLog $deployment) + { + } + + /** + * Please override this method if you want to trigger custom actions + * on a successful dump of the Icinga configuration + * + * @param DirectorDeploymentLog $deployment + */ + public function onSuccessfulDump(DirectorDeploymentLog $deployment) + { + } + + /** + * There is a typo in this method name, do not use this. + * + * @deprecated Please use onSuccessfulDump + * @param DirectorDeploymentLog $deployment + */ + public function onSuccessfullDump(DirectorDeploymentLog $deployment) + { + } + + /** + * Compatibility helper + * + * The initial version of this hook had a typo in the onSuccessfulDump method + * That's why we call this hook, which then calls both the correct and the + * erroneous method to make sure that we do not break existing implementations. + * + * @param DirectorDeploymentLog $deploymentLog + */ + final public function triggerSuccessfulDump(DirectorDeploymentLog $deploymentLog) + { + $this->onSuccessfulDump($deploymentLog); + $this->onSuccessfullDump($deploymentLog); + } + + /** + * Please override this method if you want to trigger custom actions + * once success (or failure) information have been collected for a deployed + * stage. startup_succeeded will then be filled, and startup_log might be + * available + * + * @param DirectorDeploymentLog $deployment + */ + public function onCollect(DirectorDeploymentLog $deployment) + { + } +} diff --git a/library/Director/Hook/HostFieldHook.php b/library/Director/Hook/HostFieldHook.php new file mode 100644 index 0000000..c0199d0 --- /dev/null +++ b/library/Director/Hook/HostFieldHook.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Field\FieldSpec; +use Icinga\Module\Director\Objects\IcingaHost; + +abstract class HostFieldHook +{ + public function wants(IcingaHost $host) + { + return true; + } + + /** + * @return FieldSpec + */ + abstract public function getFieldSpec(IcingaHost $host); +} diff --git a/library/Director/Hook/IcingaObjectFormHook.php b/library/Director/Hook/IcingaObjectFormHook.php new file mode 100644 index 0000000..1d20ee1 --- /dev/null +++ b/library/Director/Hook/IcingaObjectFormHook.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Web\Hook; + +abstract class IcingaObjectFormHook +{ + protected $settings = []; + + abstract public function onSetup(DirectorObjectForm $form); + + public static function callOnSetup(DirectorObjectForm $form) + { + /** @var static[] $implementations */ + $implementations = Hook::all('director/IcingaObjectForm'); + foreach ($implementations as $implementation) { + $implementation->onSetup($form); + } + } +} diff --git a/library/Director/Hook/ImportSourceHook.php b/library/Director/Hook/ImportSourceHook.php new file mode 100644 index 0000000..dba1c87 --- /dev/null +++ b/library/Director/Hook/ImportSourceHook.php @@ -0,0 +1,136 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Db; +use Icinga\Exception\ConfigurationError; + +abstract class ImportSourceHook +{ + protected $settings = []; + + public function getName() + { + $parts = explode('\\', get_class($this)); + $class = preg_replace('/ImportSource/', '', array_pop($parts)); + + if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') { + $module = array_shift($parts); + if ($module !== 'Director') { + if ($class === '') { + return sprintf('%s module', $module); + } + return sprintf('%s (%s)', $class, $module); + } + } + + return $class; + } + + public static function forImportSource(ImportSource $source) + { + $db = $source->getDb(); + $settings = $db->fetchPairs( + $db->select()->from( + 'import_source_setting', + ['setting_name', 'setting_value'] + )->where('source_id = ?', $source->get('id')) + ); + + $className = $source->get('provider_class'); + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot load import provider class %s', + $className + ); + } + + /** @var ImportSourceHook $obj */ + $obj = new $className; + $obj->setSettings($settings); + return $obj; + } + + public static function loadByName($name, Db $db) + { + $db = $db->getDbAdapter(); + $source = $db->fetchRow( + $db->select()->from( + 'import_source', + array('id', 'provider_class') + )->where('source_name = ?', $name) + ); + + $settings = $db->fetchPairs( + $db->select()->from( + 'import_source_setting', + array('setting_name', 'setting_value') + )->where('source_id = ?', $source->id) + ); + + if (! class_exists($source->provider_class)) { + throw new ConfigurationError( + 'Cannot load import provider class %s', + $source->provider_class + ); + } + /** @var ImportSourceHook $obj */ + $obj = new $source->provider_class; + $obj->setSettings($settings); + + return $obj; + } + + public function setSettings($settings) + { + $this->settings = $settings; + return $this; + } + + public function getSetting($name, $default = null) + { + if (array_key_exists($name, $this->settings)) { + return $this->settings[$name]; + } else { + return $default; + } + } + + /** + * Returns an array containing importable objects + * + * @return array + */ + abstract public function fetchData(); + + /** + * Returns a list of all available columns + * + * @return array + */ + abstract public function listColumns(); + + /** + * Override this method in case you want to suggest a default + * key column + * + * @return string|null Default key column + */ + public static function getDefaultKeyColumnName() + { + return null; + } + + /** + * Override this method if you want to extend the settings form + * + * @param QuickForm $form QuickForm that should be extended + * @return QuickForm + */ + public static function addSettingsFormFields(QuickForm $form) + { + return $form; + } +} diff --git a/library/Director/Hook/JobHook.php b/library/Director/Hook/JobHook.php new file mode 100644 index 0000000..d9a81a9 --- /dev/null +++ b/library/Director/Hook/JobHook.php @@ -0,0 +1,86 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorJob; +use Icinga\Module\Director\Web\Form\QuickForm; + +abstract class JobHook +{ + /** @var Db */ + private $db; + + /** @var DirectorJob */ + private $jobDefinition; + + public static function getDescription(QuickForm $form) + { + return false; + } + + abstract public function run(); + + public function isPending() + { + // TODO: Can be overridden, double-check whether this is necessary + } + + public function setDefinition(DirectorJob $definition) + { + $this->jobDefinition = $definition; + return $this; + } + + protected function getSetting($key, $default = null) + { + return $this->jobDefinition->getSetting($key, $default); + } + + public function getName() + { + $parts = explode('\\', get_class($this)); + $class = preg_replace('/Job$/', '', array_pop($parts)); + + if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') { + $module = array_shift($parts); + if ($module !== 'Director') { + return sprintf('%s (%s)', $class, $module); + } + } + + return $class; + } + + public function exportSettings() + { + return $this->jobDefinition->getSettings(); + } + + public static function getSuggestedRunInterval(QuickForm $form) + { + return 900; + } + + /** + * Override this method if you want to extend the settings form + * + * @param QuickForm $form QuickForm that should be extended + * @return QuickForm + */ + public static function addSettingsFormFields(QuickForm $form) + { + return $form; + } + + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + protected function db() + { + return $this->db; + } +} diff --git a/library/Director/Hook/PropertyModifierHook.php b/library/Director/Hook/PropertyModifierHook.php new file mode 100644 index 0000000..5d8736d --- /dev/null +++ b/library/Director/Hook/PropertyModifierHook.php @@ -0,0 +1,258 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Db; + +abstract class PropertyModifierHook +{ + /** @var array */ + protected $settings = []; + + /** @var string */ + private $targetProperty; + + /** @var string */ + private $propertyName; + + /** @var Db */ + private $db; + + /** @var bool */ + private $rejected = false; + + /** @var \stdClass */ + private $row; + + /** + * Methode to transform the given value + * + * Your custom property modifier needs to implement this method. + * + * @return mixed $value + */ + abstract public function transform($value); + + public function getName() + { + $parts = explode('\\', get_class($this)); + $class = preg_replace('/^PropertyModifier/', '', array_pop($parts)); // right? + + if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') { + $module = array_shift($parts); + if ($module !== 'Director') { + return sprintf('%s (%s)', $class, $module); + } + } + + return $class; + } + + /** + * Whether this PropertyModifier wants to deal with array on it's own + * + * When true, the whole array value will be passed to transform(), otherwise + * transform() will be called for every single array member + * + * @return bool + */ + public function hasArraySupport() + { + return false; + } + + /** + * This creates one cloned row for every entry of the result array + * + * When set to true and given that the property modifier returns an Array, + * the current row will be cloned for every entry of that array. The modified + * property will then be replace each time accordingly. An empty Array + * completely removes the corrent row. + * + * @return bool + */ + public function expandsRows() + { + return false; + } + + /** + * Reject this whole row + * + * Allows your property modifier to reject specific rows + * + * @param bool $reject + * @return $this + */ + public function rejectRow($reject = true) + { + $this->rejected = (bool) $reject; + + return $this; + } + + /** + * Whether this PropertyModifier wants access to the current row + * + * When true, the your modifier can access the current row via $this->getRow() + * + * @return bool + */ + public function requiresRow() + { + return false; + } + + /** + * Whether this modifier wants to reject the current row + * + * @return bool + */ + public function rejectsRow() + { + return $this->rejected; + } + + /** + * Get the current row + * + * Will be null when requiresRow was not null. Please do not modify the + * row. It might work right now, as we pass in an object reference for + * performance reasons. However, modifying row properties is not supported, + * and the outcome of such operation might change without pre-announcement + * in any future version. + * + * @return \stdClass|null + */ + public function getRow() + { + return $this->row; + } + + /** + * Sets the current row + * + * Please see requiresRow/getRow for related details. This method is called + * by the Import implementation, you should never need to call this on your + * own - apart from writing tests of course. + * + * @param \stdClass $row + * @return $this + */ + public function setRow($row) + { + $this->row = $row; + return $this; + } + + /** + * @return string + */ + public function getPropertyName() + { + return $this->propertyName; + } + + /** + * @param string $propertyName + * @return $this + */ + public function setPropertyName($propertyName) + { + $this->propertyName = $propertyName; + return $this; + } + + /** + * The desired target property. Modifiers might want to have their outcome + * written to another property of the current row. + * + * @param $property + * @return $this + */ + public function setTargetProperty($property) + { + $this->targetProperty = $property; + return $this; + } + + /** + * Whether the result of transform() should be written to a new property + * + * The Import implementation deals with this + * + * @return bool + */ + public function hasTargetProperty() + { + return $this->targetProperty !== null; + } + + /** + * Get the configured target property + * + * @return string + */ + public function getTargetProperty($default = null) + { + if ($this->targetProperty === null) { + return $default; + } + + return $this->targetProperty; + } + + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + public function getDb() + { + return $this->db; + } + + public function setSettings(array $settings) + { + $this->settings = $settings; + return $this; + } + + public function getSetting($name, $default = null) + { + if (array_key_exists($name, $this->settings)) { + return $this->settings[$name]; + } else { + return $default; + } + } + + public function setSetting($name, $value) + { + $this->settings[$name] = $value; + return $this; + } + + public function exportSettings() + { + return (object) $this->settings; + } + + public function getSettings() + { + return $this->settings; + } + + /** + * Override this method if you want to extend the settings form + * + * @param QuickForm $form QuickForm that should be extended + * @return QuickForm + */ + public static function addSettingsFormFields(QuickForm $form) + { + return $form; + } +} diff --git a/library/Director/Hook/ServiceFieldHook.php b/library/Director/Hook/ServiceFieldHook.php new file mode 100644 index 0000000..bca0836 --- /dev/null +++ b/library/Director/Hook/ServiceFieldHook.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +use Icinga\Module\Director\Field\FieldSpec; +use Icinga\Module\Director\Objects\IcingaService; + +abstract class ServiceFieldHook +{ + public function wants(IcingaService $service) + { + return true; + } + + /** + * @return FieldSpec + */ + abstract public function getFieldSpec(IcingaService $service); +} diff --git a/library/Director/Hook/ShipConfigFilesHook.php b/library/Director/Hook/ShipConfigFilesHook.php new file mode 100644 index 0000000..0026c59 --- /dev/null +++ b/library/Director/Hook/ShipConfigFilesHook.php @@ -0,0 +1,11 @@ +<?php + +namespace Icinga\Module\Director\Hook; + +abstract class ShipConfigFilesHook +{ + public function fetchFiles() + { + return array(); + } +} diff --git a/library/Director/IcingaConfig/AgentWizard.php b/library/Director/IcingaConfig/AgentWizard.php new file mode 100644 index 0000000..aceddb1 --- /dev/null +++ b/library/Director/IcingaConfig/AgentWizard.php @@ -0,0 +1,337 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaZone; +use Icinga\Module\Director\Util; +use LogicException; + +class AgentWizard +{ + protected $db; + + protected $host; + + protected $parentZone; + + protected $parentEndpoints; + + /** @var string PKI ticket */ + protected $ticket; + + public function __construct(IcingaHost $host) + { + $this->host = $host; + } + + protected function assertAgent() + { + if ($this->host->getResolvedProperty('has_agent') !== 'y') { + throw new ProgrammingError( + 'The given host "%s" is not an Agent', + $this->host->getObjectName() + ); + } + } + + protected function getCaServer() + { + return $this->db()->getDeploymentEndpointName(); + + // TODO: This is a problem with Icinga 2. Should look like this: + // return current($this->getParentEndpoints())->object_name; + } + + protected function shouldConnectToMaster() + { + return $this->host->getResolvedProperty('master_should_connect') !== 'y'; + } + + protected function getParentZone() + { + if ($this->parentZone === null) { + $this->parentZone = $this->loadParentZone(); + } + + return $this->parentZone; + } + + protected function loadParentZone() + { + $db = $this->db(); + + if ($zoneId = $this->host->getResolvedProperty('zone_id')) { + return IcingaZone::loadWithAutoIncId($zoneId, $db); + } else { + return IcingaZone::load($db->getMasterZoneName(), $db); + } + } + + protected function getParentEndpoints() + { + if ($this->parentEndpoints === null) { + $this->parentEndpoints = $this->loadParentEndpoints(); + } + + return $this->parentEndpoints; + } + + protected function loadParentEndpoints() + { + $db = $this->db()->getDbAdapter(); + + $query = $db->select() + ->from('icinga_endpoint') + ->where( + 'zone_id = ?', + $this->getParentZone()->get('id') + ); + + return IcingaEndpoint::loadAll( + $this->db(), + $query, + 'object_name' + ); + } + + /** + * Get the PKI ticket + * + * @return string + * + * @throws LogicException If ticket has not been set + */ + protected function getTicket() + { + if ($this->ticket === null) { + throw new LogicException('Ticket is null'); + } + + return $this->ticket; + } + + /** + * Set the PKI ticket + * + * @param string $ticket + * + * @return $this + */ + public function setTicket($ticket) + { + $this->ticket = $ticket; + } + + protected function loadPowershellModule() + { + return $this->getContribFile('windows-agent-installer/Icinga2Agent.psm1'); + } + + public function renderWindowsInstaller() + { + return $this->loadPowershellModule() + . "\n\n" + . 'exit Icinga2AgentModule `' . "\n " + . $this->renderPowershellParameters([ + 'AgentName' => $this->host->getEndpointName(), + 'Ticket' => $this->getTicket(), + 'ParentZone' => $this->getParentZone()->getObjectName(), + 'ParentEndpoints' => array_keys($this->getParentEndpoints()), + 'CAServer' => $this->getCaServer(), + 'RunInstaller' + ]); + } + + public function renderIcinga4WindowsWizardCommand($token) + { + $script = "Use-Icinga;\n" + . 'Start-IcingaAgentInstallWizard `' . "\n " + . $this->renderPowershellParameters([ + 'DirectorUrl' => $this->getDirectorUrl(), + 'SelfServiceAPIKey' => $token, + 'UseDirectorSelfService' => 1, + 'OverrideDirectorVars' => 0, + 'Reconfigure', + 'RunInstaller' + ]); + + return $script; + } + + public function renderPowershellModuleInstaller($token, $withModule = false) + { + if ($withModule) { + $script = $this->loadPowershellModule() . "\n\n"; + } else { + $script = ''; + } + + $script .= 'exit Icinga2AgentModule `' . "\n " + . $this->renderPowershellParameters([ + 'DirectorUrl' => $this->getDirectorUrl(), + 'DirectorAuthToken' => $token, + 'RunInstaller' + ]); + + return $script; + } + + protected function getDirectorUrl() + { + $r = Icinga::app()->getRequest(); + $scheme = $r->getServer('HTTP_X_FORWARDED_PROTO', $r->getScheme()); + + return sprintf( + '%s://%s%s/director/', + $scheme, + $r->getHttpHost(), + $r->getBaseUrl() + ); + } + + protected function renderPowershellParameters($parameters) + { + $maxKeyLength = max(array_map('strlen', array_keys($parameters))); + foreach ($parameters as $key => $value) { + if (is_int($key)) { + $maxKeyLength = max($maxKeyLength, strlen($value)); + } + } + $parts = array(); + + foreach ($parameters as $key => $value) { + if (is_int($key)) { + $parts[] = $this->renderPowershellParameter($value, null, $maxKeyLength); + } else { + $parts[] = $this->renderPowershellParameter($key, $value, $maxKeyLength); + } + } + + return implode(' `' . "\n ", $parts); + } + + protected function renderPowershellParameter($key, $value, $maxKeyLength = null) + { + $ret = '-' . $key; + if ($value === null) { + return $ret; + } + + $ret .= ' '; + + if ($maxKeyLength !== null) { + $ret .= str_repeat(' ', $maxKeyLength - strlen($key)); + } + + if (is_array($value)) { + $vals = array(); + foreach ($value as $val) { + $vals[] = $this->renderPowershellString($val); + } + $ret .= implode(', ', $vals); + } elseif (is_int($value)) { + $ret .= $value; + } else { + $ret .= $this->renderPowershellString($value); + } + + return $ret; + } + + protected function renderPowershellString($string) + { + // TODO: Escaping + return "'" . $string . "'"; + } + + protected function db() + { + if ($this->db === null) { + $this->db = $this->host->getConnection(); + } + + return $this->db; + } + + public function renderLinuxInstaller() + { + $script = $this->loadBashModule(); + + $endpoints = []; + foreach ($this->getParentEndpoints() as $endpoint) { + $endpoints[$endpoint->getObjectName()] = $endpoint->get('host'); + } + + return $this->replaceBashTemplate($script, [ + 'ICINGA2_NODENAME' => $this->host->getEndpointName(), + 'ICINGA2_CA_TICKET' => $this->getTicket(), + 'ICINGA2_PARENT_ZONE' => $this->getParentZone()->getObjectName(), + 'ICINGA2_PARENT_ENDPOINTS' => $endpoints, + 'ICINGA2_CA_NODE' => $this->getCaServer(), + 'ICINGA2_GLOBAL_ZONES' => [$this->db()->getDefaultGlobalZoneName()], + ]); + } + + protected function loadBashModule() + { + return $this->getContribFile('linux-agent-installer/Icinga2Agent.bash'); + } + + protected function replaceBashTemplate($script, $parameters) + { + foreach ($parameters as $key => $value) { + $quotedKey = preg_quote($key, '~'); + if (is_array($value)) { + $list = []; + foreach ($value as $k => $v) { + if (!is_numeric($k)) { + $v = "$k,$v"; + } + $list[] = escapeshellarg($v); + } + $value = '(' . join(' ', $list) . ')'; + } else { + $value = escapeshellarg($value); + } + $script = preg_replace("~^#?$quotedKey='@$quotedKey@'$~m", "${key}=${value}", $script); + } + + return $script; + } + + protected function renderBashParameter($key, $value) + { + $ret = $key . '='; + + // Cheating, this doesn't really help. We should ship the rendered config + if (is_array($value) && count($value) === 1) { + $value = array_shift($value); + } + + if (is_array($value)) { + $vals = array(); + foreach ($value as $val) { + $vals[] = $this->renderPowershellString($val); + } + $ret .= '(' . implode(' ', $vals) . ')'; + } else { + $ret .= $this->renderPowershellString($value); + } + + return $ret; + } + + protected function getContribDir() + { + return dirname(dirname(dirname(__DIR__))) . '/contrib'; + } + + protected function getContribFile($path) + { + return file_get_contents($this->getContribDir() . '/' . $path); + } +} diff --git a/library/Director/IcingaConfig/AssignRenderer.php b/library/Director/IcingaConfig/AssignRenderer.php new file mode 100644 index 0000000..6acbfee --- /dev/null +++ b/library/Director/IcingaConfig/AssignRenderer.php @@ -0,0 +1,268 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use gipfl\Json\JsonString; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Filter\FilterOr; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterEqualOrGreaterThan; +use Icinga\Data\Filter\FilterEqualOrLessThan; +use Icinga\Data\Filter\FilterEqual; +use Icinga\Data\Filter\FilterGreaterThan; +use Icinga\Data\Filter\FilterLessThan; +use Icinga\Data\Filter\FilterMatch; +use Icinga\Data\Filter\FilterMatchNot; +use Icinga\Data\Filter\FilterNotEqual; +use Icinga\Exception\QueryException; +use Icinga\Module\Director\Data\Json; +use InvalidArgumentException; + +class AssignRenderer +{ + protected $filter; + + public function __construct(Filter $filter) + { + $this->filter = $filter; + } + + public static function forFilter(Filter $filter) + { + return new static($filter); + } + + public function renderAssign() + { + return $this->render('assign'); + } + + public function renderIgnore() + { + return $this->render('ignore'); + } + + public function render($type) + { + return $type . ' where ' . $this->renderFilter($this->filter); + } + + protected function renderFilter(Filter $filter) + { + if ($filter instanceof FilterNot) { + $parts = []; + foreach ($filter->filters() as $sub) { + $parts[] = $this->renderFilter($sub); + } + + return '!(' . implode(' && ', $parts) . ')'; + } + if ($filter->isChain()) { + /** @var FilterChain $filter */ + return $this->renderFilterChain($filter); + } else { + /** @var FilterExpression $filter */ + return $this->renderFilterExpression($filter); + } + } + + protected function renderEquals($column, $expression) + { + if (substr($column, -7) === '.groups') { + return sprintf( + '%s in %s', + $expression, + $column + ); + } else { + return sprintf( + '%s == %s', + $column, + $expression + ); + } + } + + protected function renderNotEquals($column, $expression) + { + if (substr($column, -7) === '.groups') { + return sprintf( + '!(%s in %s)', + $expression, + $column + ); + } else { + return sprintf( + '%s != %s', + $column, + $expression + ); + } + } + + protected function renderInArray($column, $expression) + { + return sprintf( + '%s in %s', + $column, + $expression + ); + } + + protected function renderContains(FilterExpression $filter) + { + return sprintf( + '%s in %s', + $this->renderExpressionValue(json_decode($filter->getColumn())), + $filter->getExpression() + ); + } + + protected function renderFilterExpression(FilterExpression $filter) + { + if ($this->columnIsJson($filter)) { + return $this->renderContains($filter); + } + + $column = $filter->getColumn(); + $rawExpression = Json::decode($filter->getExpression()); + $expression = $this->renderExpressionValue($rawExpression); + + if (is_array($rawExpression) && $filter instanceof FilterMatch) { + return $this->renderInArray($column, $expression); + } + + if (is_string($rawExpression) && ctype_digit($rawExpression)) { + // TODO: doing this for compat reasons, should work for all filters + if ($filter instanceof FilterEqualOrGreaterThan + || $filter instanceof FilterGreaterThan + || $filter instanceof FilterEqualOrLessThan + || $filter instanceof FilterLessThan + ) { + $expression = $rawExpression; + } + } + + if ($filter instanceof FilterEqual) { + if (is_array($rawExpression)) { + return sprintf( + '%s in %s', + $column, + $expression + ); + } else { + return sprintf( + '%s == %s', + $column, + $expression + ); + } + } elseif ($filter instanceof FilterMatch) { + if ($rawExpression === true) { + return $column; + } + if ($rawExpression === false) { + return sprintf( + '! %s', + $column + ); + } + if (strpos($expression, '*') === false) { + return $this->renderEquals($column, $expression); + } else { + return sprintf( + 'match(%s, %s)', + $expression, + $column + ); + } + } elseif ($filter instanceof FilterMatchNot) { + if (strpos($expression, '*') === false) { + return $this->renderNotEquals($column, $expression); + } else { + return sprintf( + '! match(%s, %s)', + $expression, + $column + ); + } + } elseif ($filter instanceof FilterNotEqual) { + return sprintf( + '%s != %s', + $column, + $expression + ); + } elseif ($filter instanceof FilterEqualOrGreaterThan) { + return sprintf( + '%s >= %s', + $column, + $expression + ); + } elseif ($filter instanceof FilterEqualOrLessThan) { + return sprintf( + '%s <= %s', + $column, + $expression + ); + } elseif ($filter instanceof FilterGreaterThan) { + return sprintf( + '%s > %s', + $column, + $expression + ); + } elseif ($filter instanceof FilterLessThan) { + return sprintf( + '%s < %s', + $column, + $expression + ); + } else { + throw new QueryException( + 'Filter expression of type "%s" is not supported', + get_class($filter) + ); + } + } + + protected function renderExpressionValue($value) + { + return IcingaConfigHelper::renderPhpValue($value); + } + + protected function columnIsJson(FilterExpression $filter) + { + $col = $filter->getColumn(); + return strlen($col) && $col[0] === '"'; + } + + protected function renderFilterChain(FilterChain $filter) + { + // TODO: brackets if deeper level? + if ($filter instanceof FilterAnd) { + $op = ' && '; + } elseif ($filter instanceof FilterOr) { + $op = ' || '; + } elseif ($filter instanceof FilterNot) { + throw new InvalidArgumentException('renderFilterChain should never get a FilterNot instance'); + } else { + throw new InvalidArgumentException('Cannot render filter: %s', $filter); + } + + $parts = array(); + if (! $filter->isEmpty()) { + /** @var Filter $f */ + foreach ($filter->filters() as $f) { + if ($f instanceof FilterChain && $f->count() > 1) { + $parts[] = '(' . $this->renderFilter($f) . ')'; + } else { + $parts[] = $this->renderFilter($f); + } + } + } + + return implode($op, $parts); + } +} diff --git a/library/Director/IcingaConfig/ExtensibleSet.php b/library/Director/IcingaConfig/ExtensibleSet.php new file mode 100644 index 0000000..9120816 --- /dev/null +++ b/library/Director/IcingaConfig/ExtensibleSet.php @@ -0,0 +1,574 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\Objects\IcingaObject; + +class ExtensibleSet +{ + protected $ownValues; + + protected $plusValues = []; + + protected $minusValues = []; + + protected $resolvedValues; + + protected $allowedValues; + + protected $inheritedValues = []; + + protected $fromDb; + + /** + * @var IcingaObject + */ + protected $object; + + /** + * Object property name pointing to this set + * + * This also implies set table called <object_table>_<propertyName>_set + * + * @var string + */ + protected $propertyName; + + public function __construct($values = null) + { + if (null !== $values) { + $this->override($values); + } + } + + public static function forIcingaObject(IcingaObject $object, $propertyName) + { + $set = new static; + $set->object = $object; + $set->propertyName = $propertyName; + + if ($object->hasBeenLoadedFromDb()) { + $set->loadFromDb(); + } + + return $set; + } + + public function set($set) + { + if (null === $set) { + $this->reset(); + + return $this; + } elseif (is_array($set) || is_string($set)) { + $this->reset(); + $this->override($set); + } elseif (is_object($set)) { + $this->reset(); + + foreach (['override', 'extend', 'blacklist'] as $method) { + if (property_exists($set, $method)) { + $this->$method($set->$method); + } + } + } else { + throw new ProgrammingError( + 'ExtensibleSet::set accepts only plain arrays or objects' + ); + } + + return $this; + } + + public function isEmpty() + { + return $this->ownValues === null + && empty($this->plusValues) + && empty($this->minusValues); + } + + public function toPlainObject() + { + if ($this->ownValues !== null) { + if (empty($this->minusValues) && empty($this->plusValues)) { + return $this->ownValues; + } + } + + $plain = (object) []; + + if ($this->ownValues !== null) { + $plain->override = $this->ownValues; + } + if (! empty($this->plusValues)) { + $plain->extend = $this->plusValues; + } + if (! empty($this->minusValues)) { + $plain->blacklist = $this->minusValues; + } + + return $plain; + } + + public function getPlainUnmodifiedObject() + { + if ($this->fromDb === null) { + return null; + } + + $old = $this->fromDb; + + if ($old['override'] !== null) { + if (empty($old['blacklist']) && empty($old['extend'])) { + return $old['override']; + } + } + + $plain = (object) []; + + if ($old['override'] !== null) { + $plain->override = $old['override']; + } + if (! empty($old['extend'])) { + $plain->extend = $old['extend']; + } + if (! empty($old['blacklist'])) { + $plain->blacklist = $old['blacklist']; + } + + return $plain; + } + + public function hasBeenLoadedFromDb() + { + return $this->fromDb !== null; + } + + public function hasBeenModified() + { + if ($this->hasBeenLoadedFromDb()) { + if ($this->ownValues !== $this->fromDb['override']) { + return true; + } + + if ($this->plusValues !== $this->fromDb['extend']) { + return true; + } + + if ($this->minusValues !== $this->fromDb['blacklist']) { + return true; + } + + return false; + } else { + if ($this->ownValues === null + && empty($this->plusValues) + && empty($this->minusValues) + ) { + return false; + } else { + return true; + } + } + } + + protected function loadFromDb() + { + $db = $this->object->getDb(); + + $query = $db->select()->from($this->tableName(), [ + 'property', + 'merge_behaviour' + ])->where($this->foreignKey() . ' = ?', $this->object->get('id')); + + $byBehaviour = [ + 'override' => [], + 'extend' => [], + 'blacklist' => [], + ]; + + foreach ($db->fetchAll($query) as $row) { + if (! array_key_exists($row->merge_behaviour, $byBehaviour)) { + throw new ProgrammingError( + 'Got unknown merge_behaviour "%s". Schema change?', + $row->merge_behaviour + ); + } + + $byBehaviour[$row->merge_behaviour][] = $row->property; + } + + foreach ($byBehaviour as $method => &$values) { + if (empty($values)) { + continue; + } + + sort($values); + $this->$method($values); + } + + if (empty($byBehaviour['override'])) { + $byBehaviour['override'] = null; + } + + $this->fromDb = $byBehaviour; + + return $this; + } + + protected function foreignKey() + { + return $this->object->getShortTableName() . '_id'; + } + + protected function tableName() + { + return implode('_', [ + $this->object->getTableName(), + $this->propertyName, + 'set' + ]); + } + + public function getObject() + { + return $this->object; + } + + public function store() + { + if (null === $this->object) { + throw new ProgrammingError( + 'Cannot store ExtensibleSet with no assigned object' + ); + } + + if (! $this->hasBeenModified()) { + return false; + } + + $this->storeToDb(); + return true; + } + + protected function storeToDb() + { + $db = $this->object->getDb(); + + if ($db === null) { + throw new ProgrammingError( + 'Cannot store a set for an unstored related object' + ); + } + + $table = $this->tableName(); + $props = [ + $this->foreignKey() => $this->object->get('id') + ]; + + $db->delete( + $this->tableName(), + $db->quoteInto( + $this->foreignKey() . ' = ?', + $this->object->get('id') + ) + ); + + if ($this->ownValues !== null) { + $props['merge_behaviour'] = 'override'; + foreach ($this->ownValues as $value) { + $db->insert( + $table, + array_merge($props, ['property' => $value]) + ); + } + } + + if (! empty($this->plusValues)) { + $props['merge_behaviour'] = 'extend'; + foreach ($this->plusValues as $value) { + $db->insert( + $table, + array_merge($props, ['property' => $value]) + ); + } + } + + if (! empty($this->minusValues)) { + $props['merge_behaviour'] = 'blacklist'; + foreach ($this->minusValues as $value) { + $db->insert( + $table, + array_merge($props, ['property' => $value]) + ); + } + } + + $this->setBeingLoadedFromDb(); + } + + public function setBeingLoadedFromDb() + { + $this->fromDb = [ + 'override' => $this->ownValues ?: [], + 'extend' => $this->plusValues ?: [], + 'blacklist' => $this->minusValues ?: [], + ]; + } + + public function override($values) + { + $this->ownValues = []; + $this->inheritedValues = []; + + $this->addValuesTo($this->ownValues, $values); + + return $this->addResolvedValues($values); + } + + public function extend($values) + { + $this->addValuesTo($this->plusValues, $values); + return $this->addResolvedValues($values); + } + + public function blacklist($values) + { + $this->addValuesTo($this->minusValues, $values); + + if ($this->hasBeenResolved()) { + $this->removeValuesFrom($this->resolvedValues, $values); + } + + return $this; + } + + public function getResolvedValues() + { + if (! $this->hasBeenResolved()) { + $this->recalculate(); + } + + sort($this->resolvedValues); + + return $this->resolvedValues; + } + + public function inheritFrom(ExtensibleSet $parent) + { + if ($this->ownValues !== null) { + return $this; + } + + if ($this->hasBeenResolved()) { + $this->resolvedValues = null; + } + + $this->inheritedValues = []; + + $this->addValuesTo( + $this->inheritedValues, + $this->stripBlacklistedValues($parent->getResolvedValues()) + ); + + return $this->recalculate(); + } + + public function forgetInheritedValues() + { + $this->inheritedValues = []; + return $this; + } + + protected function renderArray($array) + { + $safe = []; + foreach ($array as $value) { + $safe[] = c::alreadyRendered($value); + } + + return c::renderArray($safe); + } + + public function renderAs($key, $prefix = ' ') + { + $parts = []; + + // TODO: It would be nice if we could use empty arrays to override + // inherited ones + // if ($this->ownValues !== null) { + if (!empty($this->ownValues)) { + $parts[] = c::renderKeyValue( + $key, + $this->renderArray($this->ownValues), + $prefix + ); + } + + if (!empty($this->plusValues)) { + $parts[] = c::renderKeyOperatorValue( + $key, + '+=', + $this->renderArray($this->plusValues), + $prefix + ); + } + + if (!empty($this->minusValues)) { + $parts[] = c::renderKeyOperatorValue( + $key, + '-=', + $this->renderArray($this->minusValues), + $prefix + ); + } + + return implode('', $parts); + } + + public function isRestricted() + { + return $this->allowedValues === null; + } + + public function enumAllowedValues() + { + if ($this->isRestricted()) { + throw new ProgrammingError( + 'No allowed value set available, this set is not restricted' + ); + } + + if (empty($this->allowedValues)) { + return []; + } + + return array_combine($this->allowedValues, $this->allowedValues); + } + + protected function hasBeenResolved() + { + return $this->resolvedValues !== null; + } + + protected function stripBlacklistedValues($array) + { + $this->removeValuesFrom($array, $this->minusValues); + + return $array; + } + + protected function assertValidValue($value) + { + if (null === $this->allowedValues) { + return $this; + } + + if (in_array($value, $this->allowedValues)) { + return $this; + } + + throw new InvalidPropertyException( + 'Got invalid property "%s", allowed are: (%s)', + $value, + implode(', ', $this->allowedValues) + ); + } + + protected function addValuesTo(&$array, $values) + { + foreach ($this->wantArray($values) as $value) { + // silently ignore null or empty strings + if (strlen($value) === 0) { + continue; + } + + $this->addTo($array, $value); + } + + return $this; + } + + protected function addResolvedValues($values) + { + if (! $this->hasBeenResolved()) { + $this->resolvedValues = []; + } + + return $this->addValuesTo( + $this->resolvedValues, + $this->stripBlacklistedValues($this->wantArray($values)) + ); + } + + protected function removeValuesFrom(&$array, $values) + { + foreach ($this->wantArray($values) as $value) { + $this->removeFrom($array, $value); + } + + return $this; + } + + protected function addTo(&$array, $value) + { + if (! in_array($value, $array)) { + $this->assertValidValue($value); + $array[] = $value; + } + + return $this; + } + + protected function removeFrom(&$array, $value) + { + if (false !== ($pos = array_search($value, $array))) { + unset($array[$pos]); + } + + return $this; + } + + protected function recalculate() + { + $this->resolvedValues = []; + + if ($this->ownValues === null) { + $this->addValuesTo($this->resolvedValues, $this->inheritedValues); + } else { + $this->addValuesTo($this->resolvedValues, $this->ownValues); + } + $this->addValuesTo($this->resolvedValues, $this->plusValues); + $this->removeFrom($this->resolvedValues, $this->minusValues); + + return $this; + } + + protected function reset() + { + $this->ownValues = null; + $this->plusValues = []; + $this->minusValues = []; + $this->resolvedValues = null; + $this->inheritedValues = []; + + return $this; + } + + protected function translate($string) + { + return mt('director', $string); + } + + protected function wantArray($values) + { + if (is_array($values)) { + return $values; + } + + return [$values]; + } +} diff --git a/library/Director/IcingaConfig/IcingaConfig.php b/library/Director/IcingaConfig/IcingaConfig.php new file mode 100644 index 0000000..72edd7e --- /dev/null +++ b/library/Director/IcingaConfig/IcingaConfig.php @@ -0,0 +1,781 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use Icinga\Application\Benchmark; +use Icinga\Application\Hook; +use Icinga\Application\Icinga; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\ShipConfigFilesHook; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaZone; +use InvalidArgumentException; +use LogicException; +use RuntimeException; + +class IcingaConfig +{ + protected $files = array(); + + protected $checksum; + + protected $zoneMap = array(); + + protected $lastActivityChecksum; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + protected $connection; + + protected $generationTime; + + protected $configFormat; + + protected $deploymentModeV1; + + public static $table = 'director_generated_config'; + + public function __construct(Db $connection) + { + // Make sure module hooks are loaded: + Icinga::app()->getModuleManager()->loadEnabledModules(); + + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + $this->configFormat = $this->connection->settings()->config_format; + $this->deploymentModeV1 = $this->connection->settings()->deployment_mode_v1; + } + + public function getSize() + { + $size = 0; + foreach ($this->getFiles() as $file) { + $size += $file->getSize(); + } + return $size; + } + + public function getDuration() + { + return $this->generationTime; + } + + public function getFileCount() + { + return count($this->files); + } + + public function getConfigFormat() + { + return $this->configFormat; + } + + public function getDeploymentMode() + { + if ($this->isLegacy()) { + return $this->deploymentModeV1; + } else { + throw new LogicException('There is no deployment mode for Icinga 2 config format!'); + } + } + + public function setConfigFormat($format) + { + if (! in_array($format, array('v1', 'v2'))) { + throw new InvalidArgumentException(sprintf( + 'Only Icinga v1 and v2 config format is supported, got "%s"', + $format + )); + } + + $this->configFormat = $format; + + return $this; + } + + public function isLegacy() + { + return $this->configFormat === 'v1'; + } + + public function getObjectCount() + { + $cnt = 0; + foreach ($this->getFiles() as $file) { + $cnt += $file->getObjectCount(); + } + return $cnt; + } + + public function getTemplateCount() + { + $cnt = 0; + foreach ($this->getFiles() as $file) { + $cnt += $file->getTemplateCount(); + } + return $cnt; + } + + public function getApplyCount() + { + $cnt = 0; + foreach ($this->getFiles() as $file) { + $cnt += $file->getApplyCount(); + } + return $cnt; + } + + public function getChecksum() + { + return $this->checksum; + } + + public function getHexChecksum() + { + return bin2hex($this->checksum); + } + + /** + * @return IcingaConfigFile[] + */ + public function getFiles() + { + return $this->files; + } + + public function getFileContents() + { + $result = array(); + foreach ($this->files as $name => $file) { + $result[$name] = $file->getContent(); + } + + return $result; + } + + /** + * @return array + */ + public function getFileNames() + { + return array_keys($this->files); + } + + /** + * @param string $name + * + * @return IcingaConfigFile + */ + public function getFile($name) + { + return $this->files[$name]; + } + + /** + * @param string $checksum + * @param Db $connection + * + * @return static + */ + public static function load($checksum, Db $connection) + { + $config = new static($connection); + $config->loadFromDb($checksum); + return $config; + } + + /** + * @param string $checksum + * @param Db $connection + * + * @return bool + */ + public static function exists($checksum, Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + array('c' => self::$table), + array('checksum' => $connection->dbHexFunc('c.checksum')) + )->where( + 'checksum = ?', + $connection->quoteBinary(hex2bin($checksum)) + ); + + return $db->fetchOne($query) === $checksum; + } + + public static function loadByActivityChecksum($checksum, Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + array('c' => self::$table), + array('checksum' => 'c.checksum') + )->join( + array('l' => 'director_activity_log'), + 'l.checksum = c.last_activity_checksum', + array() + )->where( + 'last_activity_checksum = ?', + $connection->quoteBinary(hex2bin($checksum)) + )->order('l.id DESC')->limit(1); + + return self::load($db->fetchOne($query), $connection); + } + + public static function existsForActivityChecksum($checksum, Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + array('c' => self::$table), + array('checksum' => $connection->dbHexFunc('c.checksum')) + )->join( + array('l' => 'director_activity_log'), + 'l.checksum = c.last_activity_checksum', + array() + )->where( + 'last_activity_checksum = ?', + $connection->quoteBinary(hex2bin($checksum)) + )->order('l.id DESC')->limit(1); + + return $db->fetchOne($query) === $checksum; + } + + /** + * @param Db $connection + * + * @return mixed + */ + public static function generate(Db $connection) + { + $config = new static($connection); + return $config->storeIfModified(); + } + + public static function wouldChange(Db $connection) + { + $config = new static($connection); + return $config->hasBeenModified(); + } + + public function hasBeenModified() + { + $this->generateFromDb(); + $this->collectExtraFiles(); + $checksum = $this->calculateChecksum(); + $activity = $this->getLastActivityChecksum(); + + $lastActivity = $this->connection->binaryDbResult( + $this->db->fetchOne( + $this->db->select()->from( + self::$table, + 'last_activity_checksum' + )->where( + 'checksum = ?', + $this->dbBin($checksum) + ) + ) + ); + + if ($lastActivity === false || $lastActivity === null) { + return true; + } + + if ($lastActivity !== $activity) { + $this->db->update( + self::$table, + array( + 'last_activity_checksum' => $this->dbBin($activity) + ), + $this->db->quoteInto('checksum = ?', $this->dbBin($checksum)) + ); + } + + return false; + } + + protected function storeIfModified() + { + if ($this->hasBeenModified()) { + $this->store(); + } + + return $this; + } + + protected function dbBin($binary) + { + return $this->connection->quoteBinary($binary); + } + + protected function calculateChecksum() + { + $files = array(); + $sortedFiles = $this->files; + ksort($sortedFiles); + /** @var IcingaConfigFile $file */ + foreach ($sortedFiles as $name => $file) { + $files[] = $name . '=' . $file->getHexChecksum(); + } + + $this->checksum = sha1(implode(';', $files), true); + return $this->checksum; + } + + public function getFilesChecksums() + { + $checksums = array(); + + /** @var IcingaConfigFile $file */ + foreach ($this->files as $name => $file) { + $checksums[] = $file->getChecksum(); + } + + return $checksums; + } + + // TODO: prepare lookup cache if empty? + public function getZoneName($id) + { + if (! array_key_exists($id, $this->zoneMap)) { + $zone = IcingaZone::loadWithAutoIncId($id, $this->connection); + $this->zoneMap[$id] = $zone->get('object_name'); + } + + return $this->zoneMap[$id]; + } + + /** + * @return self + */ + public function store() + { + $fileTable = IcingaConfigFile::$table; + $fileKey = IcingaConfigFile::$keyName; + + $existingQuery = $this->db->select() + ->from($fileTable, 'checksum') + ->where('checksum IN (?)', array_map(array($this, 'dbBin'), $this->getFilesChecksums())); + + $existing = $this->db->fetchCol($existingQuery); + + foreach ($existing as $key => $val) { + if (is_resource($val)) { + $existing[$key] = stream_get_contents($val); + } + } + + $missing = array_diff($this->getFilesChecksums(), $existing); + $stored = array(); + + /** @var IcingaConfigFile $file */ + foreach ($this->files as $name => $file) { + $checksum = $file->getChecksum(); + if (! in_array($checksum, $missing)) { + continue; + } + + if (array_key_exists($checksum, $stored)) { + continue; + } + + $stored[$checksum] = true; + + $this->db->insert( + $fileTable, + array( + $fileKey => $this->dbBin($checksum), + 'content' => $file->getContent(), + 'cnt_object' => $file->getObjectCount(), + 'cnt_template' => $file->getTemplateCount() + ) + ); + } + + $activity = $this->dbBin($this->getLastActivityChecksum()); + $this->db->beginTransaction(); + try { + $this->db->insert(self::$table, [ + 'duration' => $this->generationTime, + 'first_activity_checksum' => $activity, + 'last_activity_checksum' => $activity, + 'checksum' => $this->dbBin($this->getChecksum()), + ]); + /** @var IcingaConfigFile $file */ + foreach ($this->files as $name => $file) { + $this->db->insert('director_generated_config_file', [ + 'config_checksum' => $this->dbBin($this->getChecksum()), + 'file_checksum' => $this->dbBin($file->getChecksum()), + 'file_path' => $name, + ]); + } + $this->db->commit(); + } catch (\Exception $e) { + try { + $this->db->rollBack(); + } catch (\Exception $ignored) { + // Well... + } + + throw $e; + } + + return $this; + } + + /** + * @return self + */ + protected function generateFromDb() + { + PrefetchCache::initialize($this->connection); + $start = microtime(true); + + MemoryLimit::raiseTo('1024M'); + ini_set('max_execution_time', 0); + // Workaround for https://bugs.php.net/bug.php?id=68606 or similar + ini_set('zend.enable_gc', 0); + + if (! $this->connection->isPgsql() && $this->db->quote("1\0") !== '\'1\\0\'') { + throw new RuntimeException( + 'Refusing to render the configuration, your DB layer corrupts binary data.' + . ' You might be affected by Zend Framework bug #655' + ); + } + + $this + ->prepareGlobalBasics() + ->createFileFromDb('zone') + ->createFileFromDb('endpoint') + ->createFileFromDb('command') + ->createFileFromDb('timePeriod') + ->createFileFromDb('hostGroup') + ->createFileFromDb('host') + ->createFileFromDb('serviceGroup') + ->createFileFromDb('service') + ->createFileFromDb('serviceSet') + ->createFileFromDb('userGroup') + ->createFileFromDb('user') + ->createFileFromDb('notification') + ->createFileFromDb('dependency') + ->createFileFromDb('scheduledDowntime') + ; + + PrefetchCache::forget(); + IcingaHost::clearAllPrefetchCaches(); + + $this->generationTime = (int) ((microtime(true) - $start) * 1000); + + return $this; + } + + /** + * @return self + */ + protected function prepareGlobalBasics() + { + if ($this->isLegacy()) { + $this->configFile( + sprintf( + 'director/%s/001-director-basics', + $this->connection->getDefaultGlobalZoneName() + ), + '.cfg' + )->prepend( + $this->renderLegacyDefaultNotification() + ); + + return $this; + } + + $this->configFile( + sprintf( + 'zones.d/%s/001-director-basics', + $this->connection->getDefaultGlobalZoneName() + ) + )->prepend( + "\nconst DirectorStageDir = dirname(dirname(current_filename))\n" + . $this->renderFlappingLogHelper() + . $this->renderHostOverridableVars() + ); + + return $this; + } + + protected function renderFlappingLogHelper() + { + return ' +globals.directorWarnedOnceForThresholds = false; +globals.directorWarnOnceForThresholds = function() { + if (globals.directorWarnedOnceForThresholds == false) { + globals.directorWarnedOnceForThresholds = true + log(LogWarning, "config", "Director: flapping_threshold_high/low is not supported in this Icinga 2 version!") + } +} +'; + } + + protected function renderHostOverridableVars() + { + $settings = $this->connection->settings(); + + return sprintf( + ' +const DirectorOverrideTemplate = "%s" +if (! globals.contains(DirectorOverrideTemplate)) { + const DirectorOverrideVars = "%s" + + globals.directorWarnedOnceForServiceWithoutHost = false; + globals.directorWarnOnceForServiceWithoutHost = function() { + if (globals.directorWarnedOnceForServiceWithoutHost == false) { + globals.directorWarnedOnceForServiceWithoutHost = true + log( + LogWarning, + "config", + "Director: Custom Variable Overrides will not work in this Icinga 2 version. See Director issue #1579" + ) + } + } + + template Service DirectorOverrideTemplate { + /** + * Seems that host is missing when used in a service object, works fine for + * apply rules + */ + if (! host) { + var host = get_host(host_name) + } + if (! host) { + globals.directorWarnOnceForServiceWithoutHost() + } + + if (vars) { + vars += host.vars[DirectorOverrideVars][name] + } else { + vars = host.vars[DirectorOverrideVars][name] + } + } +} +', + $settings->override_services_templatename, + $settings->override_services_varname + ); + } + + /** + * @param string $checksum + * + * @throws NotFoundError + * + * @return self + */ + protected function loadFromDb($checksum) + { + $query = $this->db->select()->from( + self::$table, + array('checksum', 'last_activity_checksum', 'duration') + )->where('checksum = ?', $this->dbBin($checksum)); + $result = $this->db->fetchRow($query); + + if (empty($result)) { + throw new NotFoundError('Got no config for %s', bin2hex($checksum)); + } + + $this->checksum = $this->connection->binaryDbResult($result->checksum); + $this->generationTime = $result->duration; + $this->lastActivityChecksum = $this->connection->binaryDbResult($result->last_activity_checksum); + + $query = $this->db->select()->from( + array('cf' => 'director_generated_config_file'), + array( + 'file_path' => 'cf.file_path', + 'checksum' => 'f.checksum', + 'content' => 'f.content', + 'cnt_object' => 'f.cnt_object', + 'cnt_template' => 'f.cnt_template', + 'cnt_apply' => 'f.cnt_apply', + ) + )->join( + array('f' => 'director_generated_file'), + 'cf.file_checksum = f.checksum', + array() + )->where('cf.config_checksum = ?', $this->dbBin($checksum)); + + foreach ($this->db->fetchAll($query) as $row) { + $file = new IcingaConfigFile(); + $this->files[$row->file_path] = $file + ->setContent($row->content) + ->setObjectCount($row->cnt_object) + ->setTemplateCount($row->cnt_template) + ->setApplyCount($row->cnt_apply); + } + + return $this; + } + + protected function createFileFromDb($type) + { + /** @var IcingaObject $class */ + $class = 'Icinga\\Module\\Director\\Objects\\Icinga' . ucfirst($type); + Benchmark::measure(sprintf('Prefetching %s', $type)); + $objects = $class::prefetchAll($this->connection); + return $this->createFileForObjects($type, $objects); + } + + /** + * @param string $type Short object type, like 'service' or 'zone' + * @param IcingaObject[] $objects + * + * @return self + */ + protected function createFileForObjects($type, $objects) + { + if (empty($objects)) { + return $this; + } + + Benchmark::measure(sprintf('Generating %ss: %s', $type, count($objects))); + foreach ($objects as $object) { + if ($object->isExternal()) { + if ($type === 'zone') { + $this->zoneMap[$object->get('id')] = $object->getObjectName(); + } + } + $object->renderToConfig($this); + } + + Benchmark::measure(sprintf('%ss done', $type)); + return $this; + } + + protected function typeWantsGlobalZone($type) + { + $types = array( + 'command', + ); + + return in_array($type, $types); + } + + protected function typeWantsMasterZone($type) + { + $types = array( + 'host', + 'hostGroup', + 'service', + 'serviceGroup', + 'endpoint', + 'user', + 'userGroup', + 'timePeriod', + 'notification', + 'dependency' + ); + + return in_array($type, $types); + } + + /** + * @param string $name Relative config file name + * @param string $suffix Config file suffix, defaults to '.conf' + * + * @return IcingaConfigFile + */ + public function configFile($name, $suffix = '.conf') + { + $filename = $name . $suffix; + if (! array_key_exists($filename, $this->files)) { + $this->files[$filename] = new IcingaConfigFile(); + } + + return $this->files[$filename]; + } + + protected function collectExtraFiles() + { + /** @var ShipConfigFilesHook $hook */ + foreach (Hook::all('Director\\ShipConfigFiles') as $hook) { + foreach ($hook->fetchFiles() as $filename => $file) { + if (array_key_exists($filename, $this->files)) { + throw new LogicException(sprintf( + 'Cannot ship one file twice: %s', + $filename + )); + } + if ($file instanceof IcingaConfigFile) { + $this->files[$filename] = $file; + } else { + $this->configFile($filename, '')->setContent((string) $file); + } + } + } + + return $this; + } + + public function getLastActivityHexChecksum() + { + return bin2hex($this->getLastActivityChecksum()); + } + + /** + * @return mixed + */ + public function getLastActivityChecksum() + { + if ($this->lastActivityChecksum === null) { + $query = $this->db->select() + ->from('director_activity_log', 'checksum') + ->order('id DESC') + ->limit(1); + + $this->lastActivityChecksum = $this->db->fetchOne($query); + + // PgSQL workaround: + if (is_resource($this->lastActivityChecksum)) { + $this->lastActivityChecksum = stream_get_contents($this->lastActivityChecksum); + } + } + + return $this->lastActivityChecksum; + } + + protected function renderLegacyDefaultNotification() + { + return preg_replace('~^ {12}~m', '', ' + # + # Default objects to avoid warnings + # + + define contact { + contact_name icingaadmin + alias Icinga Admin + host_notifications_enabled 0 + host_notification_commands notify-never-default + host_notification_period notification_none + service_notifications_enabled 0 + service_notification_commands notify-never-default + service_notification_period notification_none + } + + define contactgroup { + contactgroup_name icingaadmins + members icingaadmin + } + + define timeperiod { + timeperiod_name notification_none + alias No Notifications + } + + define command { + command_name notify-never-default + command_line /bin/echo "NOOP" + } + '); + } +} diff --git a/library/Director/IcingaConfig/IcingaConfigFile.php b/library/Director/IcingaConfig/IcingaConfigFile.php new file mode 100644 index 0000000..109eb8a --- /dev/null +++ b/library/Director/IcingaConfig/IcingaConfigFile.php @@ -0,0 +1,168 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Util; + +class IcingaConfigFile +{ + public static $table = 'director_generated_file'; + + public static $keyName = 'checksum'; + + protected $content; + + protected $checksum; + + protected $cntObject = 0; + + protected $cntTemplate = 0; + + protected $cntApply = 0; + + /** + * @param $content + * + * @return self + */ + public function prepend($content) + { + $this->content = $content . $this->content; + $this->checksum = null; + return $this; + } + + public function getContent() + { + return $this->content; + } + + public function setContent($content) + { + $this->content = $content; + $this->checksum = null; + return $this; + } + + public function addContent($content) + { + if ($this->content === null) { + $this->content = $content; + } else { + $this->content .= $content; + } + $this->checksum = null; + return $this; + } + + public function getObjectCount() + { + return $this->cntObject; + } + + public function getTemplateCount() + { + return $this->cntTemplate; + } + + public function getApplyCount() + { + return $this->cntApply; + } + + public function getSize() + { + return strlen($this->content); + } + + public function setObjectCount($cnt) + { + $this->cntObject = $cnt; + return $this; + } + + public function setTemplateCount($cnt) + { + $this->cntTemplate = $cnt; + return $this; + } + + public function setApplyCount($cnt) + { + $this->cntApply = $cnt; + return $this; + } + + public function getHexChecksum() + { + return bin2hex($this->getChecksum()); + } + + public function getChecksum() + { + if ($this->checksum === null) { + $this->checksum = sha1($this->content, true); + } + + return $this->checksum; + } + + public function addLegacyObjects($objects) + { + foreach ($objects as $object) { + $this->addLegacyObject($object); + } + + return $this; + } + + public function addObjects($objects) + { + foreach ($objects as $object) { + $this->addObject($object); + } + + return $this; + } + + public function addObject(IcingaObject $object) + { + $this->content .= $object->toConfigString(); + $this->checksum = null; + return $this->addObjectStats($object); + } + + public function addLegacyObject(IcingaObject $object) + { + $this->content .= $object->toLegacyConfigString(); + $this->checksum = null; + return $this->addObjectStats($object); + } + + protected function addObjectStats(IcingaObject $object) + { + if ($object->hasProperty('object_type')) { + $type = $object->object_type; + + switch ($type) { + case 'object': + $this->cntObject++; + break; + case 'template': + $this->cntTemplate++; + break; + case 'apply': + $this->cntApply++; + break; + } + } + + return $this; + } + + public function __toString() + { + return $this->getContent(); + } +} diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php new file mode 100644 index 0000000..03c017e --- /dev/null +++ b/library/Director/IcingaConfig/IcingaConfigHelper.php @@ -0,0 +1,430 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use InvalidArgumentException; +use function ctype_digit; +use function explode; +use function floor; +use function implode; +use function preg_match; +use function preg_split; +use function sprintf; +use function strlen; +use function strpos; +use function substr; + +class IcingaConfigHelper +{ + /** + * Reserved words according to + * https://icinga.com/docs/icinga2/latest/doc/17-language-reference/#reserved-keywords + */ + protected static $reservedWords = [ + 'object', + 'template', + 'include', + 'include_recursive', + 'include_zones', + 'library', + 'null', + 'true', + 'false', + 'const', + 'var', + 'this', + 'globals', + 'locals', + 'use', + 'default', + 'ignore_on_error', + 'current_filename', + 'current_line', + 'apply', + 'to', + 'where', + 'import', + 'assign', + 'ignore', + 'function', + 'return', + 'break', + 'continue', + 'for', + 'if', + 'else', + 'while', + 'throw', + 'try', + 'except', + 'in', + 'using', + 'namespace', + ]; + + public static function renderKeyValue($key, $value, $prefix = ' ') + { + return self::renderKeyOperatorValue($key, '=', $value, $prefix); + } + + public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ') + { + $string = sprintf( + "%s %s %s", + $key, + $operator, + $value + ); + + if ($prefix && strpos($string, "\n") !== false) { + return $prefix . implode("\n" . $prefix, explode("\n", $string)) . "\n"; + } + + return $prefix . $string . "\n"; + } + + public static function renderBoolean($value) + { + if ($value === 'y' || $value === true) { + return 'true'; + } + if ($value === 'n' || $value === false) { + return 'false'; + } + + throw new InvalidArgumentException(sprintf( + '%s is not a valid boolean', + $value + )); + } + + protected static function renderInteger($value) + { + return (string) $value; + } + + public static function renderFloat($value) + { + // Render .0000 floats as integers, mainly because of some JSON + // implementations: + if ((string) (int) $value === (string) $value) { + return static::renderInteger((int) $value); + } + + return sprintf('%F', $value); + } + + protected static function renderNull() + { + return 'null'; + } + + // TODO: Find out how to allow multiline {{{...}}} strings. + // Parameter? Dedicated method? Always if \n is found? + public static function renderString($string) + { + $special = [ + '/\\\/', + '/"/', + '/\$/', + '/\t/', + '/\r/', + '/\n/', + // '/\b/', -> doesn't work + '/\f/', + ]; + + $replace = [ + '\\\\\\', + '\\"', + '\\$', + '\\t', + '\\r', + '\\n', + // '\\b', + '\\f', + ]; + + $string = preg_replace($special, $replace, $string); + + return '"' . $string . '"'; + } + + public static function renderPhpValue($value) + { + if (is_null($value)) { + return static::renderNull(); + } + if (is_bool($value)) { + return static::renderBoolean($value); + } + if (is_int($value)) { + return static::renderInteger($value); + } + if (is_float($value)) { + return static::renderFloat($value); + } + // TODO: + // if (is_object($value) || static::isAssocArray($value)) { + // return static::renderHash($value, $prefix) + // TODO: also check array + if (is_array($value)) { + return static::renderArray($value); + } + if (is_string($value)) { + return static::renderString($value); + } + + throw new InvalidArgumentException(sprintf( + 'Unexpected type %s', + var_export($value, 1) + )); + } + + public static function renderDictionaryKey($key) + { + if (preg_match('/^[a-z_]+[a-z0-9_]*$/i', $key)) { + return static::escapeIfReserved($key); + } + + return static::renderString($key); + } + + // Requires an array + public static function renderArray($array) + { + $data = []; + foreach ($array as $entry) { + if ($entry instanceof IcingaConfigRenderer) { + $data[] = $entry; + } else { + $data[] = self::renderString($entry); + } + } + + return static::renderEscapedArray($data); + } + + public static function renderEscapedArray($array) + { + $str = '[ ' . implode(', ', $array) . ' ]'; + + if (strlen($str) < 60) { + return $str; + } + + // Prefix for toConfigString? + return "[\n " . implode(",\n ", $array) . "\n]"; + } + + public static function renderDictionary($dictionary) + { + $values = []; + foreach ($dictionary as $key => $value) { + $values[$key] = rtrim( + self::renderKeyValue( + self::renderDictionaryKey($key), + $value + ) + ); + } + + if (empty($values)) { + return '{}'; + } + ksort($values, SORT_STRING); + + // Prefix for toConfigString? + return "{\n" . implode("\n", $values) . "\n}"; + } + + public static function renderExpression($string) + { + return "{{\n " . $string . "\n}}"; + } + + public static function alreadyRendered($string) + { + return new IcingaConfigRendered($string); + } + + public static function isReserved($string) + { + return in_array($string, self::$reservedWords, true); + } + + public static function escapeIfReserved($string) + { + if (self::isReserved($string)) { + return '@' . $string; + } + + return $string; + } + + public static function isValidInterval($interval) + { + if (ctype_digit($interval)) { + return true; + } + + $parts = preg_split('/\s+/', $interval, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $part) { + if (! preg_match('/^(\d+)([dhms]?)$/', $part)) { + return false; + } + } + + return true; + } + + public static function parseInterval($interval) + { + if ($interval === null || $interval === '') { + return null; + } + + if (is_int($interval) || ctype_digit($interval)) { + return (int) $interval; + } + + $parts = preg_split('/\s+/', $interval, -1, PREG_SPLIT_NO_EMPTY); + $value = 0; + foreach ($parts as $part) { + if (! preg_match('/^(\d+)([dhms]?)$/', $part, $m)) { + throw new InvalidArgumentException(sprintf( + '"%s" is not a valid time (duration) definition', + $interval + )); + } + + switch ($m[2]) { + case 'd': + $value += $m[1] * 86400; + break; + case 'h': + $value += $m[1] * 3600; + break; + case 'm': + $value += $m[1] * 60; + break; + default: + $value += (int) $m[1]; + } + } + + return $value; + } + + public static function renderInterval($interval) + { + // TODO: compat only, do this at munge time. All db fields should be int + $seconds = self::parseInterval($interval); + if ($seconds === 0) { + return '0s'; + } + + $steps = [ + 'd' => 86400, + 'h' => 3600, + 'm' => 60, + ]; + + foreach ($steps as $unit => $duration) { + if ($seconds % $duration === 0) { + return (int) floor($seconds / $duration) . $unit; + } + } + + return $seconds . 's'; + } + + public static function stringHasMacro($string, $macroName = null) + { + $len = strlen($string); + $start = false; + // TODO: robust UTF8 support. It works, but it is not 100% correct + for ($i = 0; $i < $len; $i++) { + if ($string[$i] === '$') { + if ($start === false) { + $start = $i; + } else { + // Escaping, $$ + if ($start + 1 === $i) { + $start = false; + } else { + if ($macroName === null) { + return true; + } + if ($macroName === substr($string, $start + 1, $i - $start - 1)) { + return true; + } + + $start = false; + } + } + } + } + + return false; + } + + /** + * Hint: this isn't complete, but let's restrict ourselves right now + * + * @param $name + * @return bool + */ + public static function isValidMacroName($name) + { + return preg_match('/^[A-z_][A-z_.\d]+$/', $name) + && ! preg_match('/\.$/', $name); + } + + public static function renderStringWithVariables($string, array $whiteList = null) + { + $len = strlen($string); + $start = false; + $parts = []; + // TODO: UTF8... + $offset = 0; + for ($i = 0; $i < $len; $i++) { + if ($string[$i] === '$') { + if ($start === false) { + $start = $i; + } else { + // Ignore $$ + if ($start + 1 === $i) { + $start = false; + } else { + // We got a macro + $macroName = substr($string, $start + 1, $i - $start - 1); + if (static::isValidMacroName($macroName)) { + if ($whiteList === null || in_array($macroName, $whiteList)) { + if ($start > $offset) { + $parts[] = static::renderString( + substr($string, $offset, $start - $offset) + ); + } + $parts[] = $macroName; + $offset = $i + 1; + } + } + + $start = false; + } + } + } + } + + if ($offset < $i) { + $parts[] = static::renderString(substr($string, $offset, $i - $offset)); + } + + if (! empty($parts)) { + return implode(' + ', $parts); + } + + return '""'; + } +} diff --git a/library/Director/IcingaConfig/IcingaConfigRendered.php b/library/Director/IcingaConfig/IcingaConfigRendered.php new file mode 100644 index 0000000..90b710e --- /dev/null +++ b/library/Director/IcingaConfig/IcingaConfigRendered.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use InvalidArgumentException; + +class IcingaConfigRendered implements IcingaConfigRenderer +{ + protected $rendered; + + public function __construct($string) + { + if (! is_string($string)) { + throw new InvalidArgumentException('IcingaConfigRendered accepts only strings'); + } + + $this->rendered = $string; + } + + public function toConfigString() + { + return $this->rendered; + } + + public function __toString() + { + return $this->toConfigString(); + } + + public function toLegacyConfigString() + { + return $this->rendered; + } +} diff --git a/library/Director/IcingaConfig/IcingaConfigRenderer.php b/library/Director/IcingaConfig/IcingaConfigRenderer.php new file mode 100644 index 0000000..108956d --- /dev/null +++ b/library/Director/IcingaConfig/IcingaConfigRenderer.php @@ -0,0 +1,10 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +interface IcingaConfigRenderer +{ + public function toConfigString(); + public function toLegacyConfigString(); + public function __toString(); +} diff --git a/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php b/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php new file mode 100644 index 0000000..38d93ee --- /dev/null +++ b/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php @@ -0,0 +1,110 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +use InvalidArgumentException; + +class IcingaLegacyConfigHelper +{ + public static function renderKeyValue($key, $value, $prefix = ' ') + { + return self::renderKeyOperatorValue($key, "\t", $value, $prefix); + } + + public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ') + { + $string = sprintf( + "%s%s%s", + $key, + $operator, + $value + ); + + if ($prefix && strpos($string, "\n") !== false) { + return $prefix . implode("\n" . $prefix, explode("\n", $string)) . "\n"; + } + + return $prefix . $string . "\n"; + } + + public static function renderBoolean($value) + { + if ($value === 'y') { + return '1'; + } elseif ($value === 'n') { + return '0'; + } else { + throw new InvalidArgumentException('%s is not a valid boolean', $value); + } + } + + // TODO: Double-check legacy "encoding" + public static function renderString($string) + { + $special = [ + '/\\\/', + '/\$/', + '/\t/', + '/\r/', + '/\n/', + // '/\b/', -> doesn't work + '/\f/', + ]; + + $replace = [ + '\\\\\\', + '\\$', + '\\t', + '\\r', + '\\n', + // '\\b', + '\\f', + ]; + + $string = preg_replace($special, $replace, $string); + + return $string; + } + + /** + * @param array $array + * @return string + */ + public static function renderArray($array) + { + $data = []; + foreach ($array as $entry) { + if ($entry instanceof IcingaConfigRenderer) { + // $data[] = $entry; + $data[] = 'INVALID_ARRAY_MEMBER'; + } else { + $data[] = self::renderString($entry); + } + } + + return implode(', ', $data); + } + + public static function renderDictionary($dictionary) + { + return 'INVALID_DICTIONARY'; + } + + public static function renderExpression($string) + { + return 'INVALID_EXPRESSION'; + } + + public static function alreadyRendered($string) + { + return new IcingaConfigRendered($string); + } + + public static function renderInterval($interval) + { + if ($interval < 60) { + $interval = 60; + } + return $interval / 60; + } +} diff --git a/library/Director/IcingaConfig/StateFilterSet.php b/library/Director/IcingaConfig/StateFilterSet.php new file mode 100644 index 0000000..7a2daec --- /dev/null +++ b/library/Director/IcingaConfig/StateFilterSet.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +class StateFilterSet extends ExtensibleSet +{ + protected $allowedValues = array( + 'Up', + 'Down', + 'OK', + 'Warning', + 'Critical', + 'Unknown', + ); + + public function enumAllowedValues() + { + return array( + $this->translate('Hosts') => array( + 'Up' => $this->translate('Up'), + 'Down' => $this->translate('Down') + ), + $this->translate('Services') => array( + 'OK' => $this->translate('OK'), + 'Warning' => $this->translate('Warning'), + 'Critical' => $this->translate('Critical'), + 'Unknown' => $this->translate('Unknown'), + ), + ); + } +} diff --git a/library/Director/IcingaConfig/TypeFilterSet.php b/library/Director/IcingaConfig/TypeFilterSet.php new file mode 100644 index 0000000..dffd4cf --- /dev/null +++ b/library/Director/IcingaConfig/TypeFilterSet.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Director\IcingaConfig; + +class TypeFilterSet extends ExtensibleSet +{ + protected $allowedValues = array( + 'Problem', + 'Recovery', + 'Custom', + 'Acknowledgement', + 'DowntimeStart', + 'DowntimeEnd', + 'DowntimeRemoved', + 'FlappingStart', + 'FlappingEnd', + ); + + public function enumAllowedValues() + { + return array( + $this->translate('State changes') => array( + 'Problem' => $this->translate('Problem'), + 'Recovery' => $this->translate('Recovery'), + 'Custom' => $this->translate('Custom notification'), + ), + $this->translate('Problem handling') => array( + 'Acknowledgement' => $this->translate('Acknowledgement'), + 'DowntimeStart' => $this->translate('Downtime starts'), + 'DowntimeEnd' => $this->translate('Downtime ends'), + 'DowntimeRemoved' => $this->translate('Downtime removed'), + ), + $this->translate('Flapping') => array( + 'FlappingStart' => $this->translate('Flapping starts'), + 'FlappingEnd' => $this->translate('Flapping ends') + ) + ); + } +} diff --git a/library/Director/Import/Import.php b/library/Director/Import/Import.php new file mode 100644 index 0000000..f82454d --- /dev/null +++ b/library/Director/Import/Import.php @@ -0,0 +1,481 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Exception; +use Icinga\Application\Benchmark; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Data\RecursiveUtf8Validator; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Util; +use stdClass; + +class Import +{ + /** + * @var ImportSource + */ + protected $source; + + /** + * @var Db + */ + protected $connection; + + /** + * @var \Zend_Db_Adapter_Abstract + */ + protected $db; + + /** + * Raw data that should be imported, array of stdClass objects + * + * @var array + */ + protected $data; + + /** + * Checksum of the rowset that should be imported + * + * @var string + */ + private $rowsetChecksum; + + /** + * Checksum-indexed rows + * + * @var array + */ + private $rows; + + /** + * Checksum-indexed row -> property + * + * @var array + */ + private $rowProperties; + + /** + * Whether this rowset exists, for caching purposes + * + * @var boolean + */ + private $rowsetExists; + + protected $properties = array(); + + /** + * Checksums of all rows + */ + private $rowChecksums; + + public function __construct(ImportSource $source) + { + $this->source = $source; + $this->connection = $source->getConnection(); + $this->db = $this->connection->getDbAdapter(); + } + + /** + * Whether this import provides modified data + * + * @return boolean + */ + public function providesChanges() + { + return ! $this->rowsetExists() + || ! $this->lastRowsetIs($this->rowsetChecksum()); + } + + /** + * Trigger an import run + * + * @return int Last import run ID + */ + public function run() + { + if ($this->providesChanges() && ! $this->rowsetExists()) { + $this->storeRowset(); + } + + $this->db->insert( + 'import_run', + array( + 'source_id' => $this->source->get('id'), + 'rowset_checksum' => $this->quoteBinary($this->rowsetChecksum()), + 'start_time' => date('Y-m-d H:i:s'), + 'succeeded' => 'y' + ) + ); + if ($this->connection->isPgsql()) { + return $this->db->lastInsertId('import_run', 'id'); + } else { + return $this->db->lastInsertId(); + } + } + + /** + * Whether there are no rows to be fetched from import source + * + * @return boolean + */ + public function isEmpty() + { + $rows = $this->checksummedRows(); + return empty($rows); + } + + /** + * Checksum of all available rows + * + * @return string + */ + protected function & rowsetChecksum() + { + if ($this->rowsetChecksum === null) { + $this->prepareChecksummedRows(); + } + + return $this->rowsetChecksum; + } + + /** + * All rows + * + * @return array + */ + protected function & checksummedRows() + { + if ($this->rows === null) { + $this->prepareChecksummedRows(); + } + + return $this->rows; + } + + /** + * Checksum of all available rows + * + * @return array + */ + protected function & rawData() + { + if ($this->data === null) { + $this->data = ImportSourceHook::forImportSource( + $this->source + )->fetchData(); + Benchmark::measure('Fetched all data from Import Source'); + $this->source->applyModifiers($this->data); + Benchmark::measure('Applied Property Modifiers to imported data'); + } + + return $this->data; + } + + /** + * Prepare and remember an ImportedProperty + * + * @param string $key + * @param mixed $rawValue + * + * @return array + */ + protected function prepareImportedProperty($key, $rawValue) + { + if (is_array($rawValue) || is_bool($rawValue) || is_int($rawValue) || is_float($rawValue)) { + $value = json_encode($rawValue); + $format = 'json'; + } elseif ($rawValue instanceof stdClass) { + $value = json_encode($this->sortObject($rawValue)); + $format = 'json'; + } else { + $value = $rawValue; + $format = 'string'; + } + + $checksum = sha1(sprintf('%s=(%s)%s', $key, $format, $value), true); + + if (! array_key_exists($checksum, $this->properties)) { + $this->properties[$checksum] = array( + 'checksum' => $this->quoteBinary($checksum), + 'property_name' => $key, + 'property_value' => $value, + 'format' => $format + ); + } + + return $this->properties[$checksum]; + } + + /** + * Walk through each row, prepare properties and calculate checksums + */ + protected function prepareChecksummedRows() + { + $keyColumn = $this->source->get('key_column'); + $this->rows = array(); + $this->rowProperties = array(); + $objects = array(); + $rowCount = 0; + + foreach ($this->rawData() as $row) { + $rowCount++; + + // Key column must be set + if (! isset($row->$keyColumn)) { + throw new IcingaException( + 'No key column "%s" in row %d', + $keyColumn, + $rowCount + ); + } + + $object_name = $row->$keyColumn; + + // Check for name collision + if (array_key_exists($object_name, $objects)) { + throw new IcingaException( + 'Duplicate entry: %s', + $object_name + ); + } + + $rowChecksums = array(); + $keys = array_keys((array) $row); + sort($keys); + + foreach ($keys as $key) { + // TODO: Specify how to treat NULL values. Ignoring for now. + // One option might be to import null (checksum '(null)') + // and to provide a flag at sync time + if ($row->$key === null) { + continue; + } + + $property = $this->prepareImportedProperty($key, $row->$key); + $rowChecksums[] = $property['checksum']; + } + + $checksum = sha1($object_name . ';' . implode(';', $rowChecksums), true); + if (array_key_exists($checksum, $this->rows)) { + die('WTF, collision?'); + } + + $this->rows[$checksum] = array( + 'checksum' => $this->quoteBinary($checksum), + 'object_name' => $object_name + ); + + $this->rowProperties[$checksum] = $rowChecksums; + + $objects[$object_name] = $checksum; + } + + $this->rowChecksums = array_keys($this->rows); + $this->rowsetChecksum = sha1(implode(';', $this->rowChecksums), true); + return $this; + } + + /** + * Store our new rowset + */ + protected function storeRowset() + { + $db = $this->db; + $rowset = $this->rowsetChecksum(); + $rows = $this->checksummedRows(); + + $db->beginTransaction(); + + try { + if ($this->isEmpty()) { + $newRows = array(); + $newProperties = array(); + } else { + $newRows = $this->newChecksums('imported_row', $this->rowChecksums); + $newProperties = $this->newChecksums('imported_property', array_keys($this->properties)); + } + + $db->insert('imported_rowset', array('checksum' => $this->quoteBinary($rowset))); + + foreach ($newProperties as $checksum) { + $db->insert('imported_property', $this->properties[$checksum]); + } + + foreach ($newRows as $row) { + try { + $db->insert('imported_row', $rows[$row]); + foreach ($this->rowProperties[$row] as $property) { + $db->insert('imported_row_property', array( + 'row_checksum' => $this->quoteBinary($row), + 'property_checksum' => $property + )); + } + } catch (Exception $e) { + throw new IcingaException( + "Error while storing a row for '%s' into database: %s", + $rows[$row]['object_name'], + $e->getMessage() + ); + } + } + + foreach (array_keys($rows) as $row) { + $db->insert( + 'imported_rowset_row', + array( + 'rowset_checksum' => $this->quoteBinary($rowset), + 'row_checksum' => $this->quoteBinary($row) + ) + ); + } + + $db->commit(); + + $this->rowsetExists = true; + } catch (Exception $e) { + try { + $db->rollBack(); + } catch (Exception $e) { + // Well... + } + // Eventually throws details for invalid UTF8 characters + RecursiveUtf8Validator::validateRows($this->data); + throw $e; + } + } + + /** + * Whether the last run of this import matches the given checksum + * + * @param string $checksum Binary checksum + * + * @return bool + */ + protected function lastRowsetIs($checksum) + { + return $this->connection->getLatestImportedChecksum($this->source->get('id')) + === bin2hex($checksum); + } + + /** + * Whether our rowset already exists in the database + * + * @return boolean + */ + protected function rowsetExists() + { + if (null === $this->rowsetExists) { + $this->rowsetExists = 0 === count( + $this->newChecksums( + 'imported_rowset', + array($this->rowsetChecksum()) + ) + ); + } + + return $this->rowsetExists; + } + + /** + * Finde new checksums for a specific table + * + * Accepts an array of checksums and gives you an array with those checksums + * that are missing in the given table + * + * @param string $table Database table name + * @param array $checksums Array with the checksums that should be verified + * + * @return array + */ + protected function newChecksums($table, $checksums) + { + $db = $this->db; + + // TODO: The following is a quickfix for binary data corrpution reported + // in https://github.com/zendframework/zf1/issues/655 caused by + // https://github.com/zendframework/zf1/commit/2ac9c30f + // + // Should be reverted once fixed, eventually with a check continueing + // to use this workaround for specific ZF versions (1.12.16 and 1.12.17 + // so far). Alternatively we could also use a custom quoteInto method. + + // The former query looked as follows: + // + // $query = $db->select()->from($table, 'checksum') + // ->where('checksum IN (?)', $checksums) + // ... + // return array_diff($checksums, $existing); + + $hexed = array_map('bin2hex', $checksums); + + $conn = $this->connection; + $query = $db + ->select() + ->from( + array('c' => $table), + array('checksum' => $conn->dbHexFunc('c.checksum')) + )->where( + $conn->dbHexFunc('c.checksum') . ' IN (?)', + $hexed + ); + + $existing = $db->fetchCol($query); + $new = array_diff($hexed, $existing); + + return array_map('hex2bin', $new); + } + + /** + * Sort a given stdClass object by property name + * + * @param stdClass $object + * + * @return object + */ + protected function sortObject($object) + { + $array = (array) $object; + foreach ($array as $key => $val) { + $this->sortElement($val); + } + ksort($array); + return (object) $array; + } + + /** + * Walk through a given array and sort all children + * + * Please note that the array itself will NOT be sorted, as arrays must + * keep their ordering + * + * @param array $array + */ + protected function sortArrayObject(&$array) + { + foreach ($array as $key => $val) { + $this->sortElement($val); + } + } + + /** + * Recursively sort a given property + * + * @param mixed $el + */ + protected function sortElement(&$el) + { + if (is_array($el)) { + $this->sortArrayObject($el); + } elseif ($el instanceof stdClass) { + $el = $this->sortObject($el); + } + } + + protected function quoteBinary($bin) + { + return $this->connection->quoteBinary($bin); + } +} diff --git a/library/Director/Import/ImportSourceCoreApi.php b/library/Director/Import/ImportSourceCoreApi.php new file mode 100644 index 0000000..6d590ec --- /dev/null +++ b/library/Director/Import/ImportSourceCoreApi.php @@ -0,0 +1,92 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Icinga\Application\Config; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class ImportSourceCoreApi extends ImportSourceHook +{ + protected $connection; + + protected $db; + + protected $api; + + public function fetchData() + { + $func = 'get' . $this->getSetting('object_type') . 'Objects'; + $objects = $this->api()->$func(); + $result = array(); + foreach ($objects as $object) { + $result[] = $object->toPlainObject(); + } + + return $result; + } + + public function listColumns() + { + $res = $this->fetchData(); + if (empty($data)) { + return array('object_name'); + } + + return array_keys((array) $res[0]); + } + + public static function getDefaultKeyColumnName() + { + return 'object_name'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'object_type', array( + 'label' => 'Object type', + 'required' => true, + 'multiOptions' => $form->optionalEnum(self::enumObjectTypes($form)) + )); + } + + protected static function enumObjectTypes($form) + { + $types = array( + 'CheckCommand' => $form->translate('Check Commands'), + 'NotificationCommand' => $form->translate('Notification Commands'), + 'Endpoint' => $form->translate('Endpoints'), + 'Host' => $form->translate('Hosts'), + 'HostGroup' => $form->translate('Hostgroups'), + 'User' => $form->translate('Users'), + 'UserGroup' => $form->translate('Usergroups'), + 'Zone' => $form->translate('Zones'), + ); + + asort($types); + return $types; + } + + protected function api() + { + if ($this->api === null) { + $endpoint = $this->db()->getDeploymentEndpoint(); + $this->api = $endpoint->api()->setDb($this->db()); + } + + return $this->api; + } + + protected function db() + { + if ($this->db === null) { + $resourceName = Config::module('director')->get('db', 'resource'); + if ($resourceName) { + $this->db = Db::fromResourceName($resourceName); + } + } + + return $this->db; + } +} diff --git a/library/Director/Import/ImportSourceDirectorObject.php b/library/Director/Import/ImportSourceDirectorObject.php new file mode 100644 index 0000000..e3f56fc --- /dev/null +++ b/library/Director/Import/ImportSourceDirectorObject.php @@ -0,0 +1,120 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Icinga\Application\Config; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Forms\ImportSourceForm; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Form\QuickForm; + +class ImportSourceDirectorObject extends ImportSourceHook +{ + protected $db; + + public function getName() + { + return 'Director Objects'; + } + + public static function getDefaultKeyColumnName() + { + return 'object_name'; + } + + public function fetchData() + { + $db = $this->db(); + $objectClass = $this->getSetting('object_class'); + $objectType = $this->getSetting('object_type'); + /** @var IcingaObject $class fake type hint, it's a string */ + $class = DbObjectTypeRegistry::classByType($objectClass); + if ($objectType) { + $dummy = $class::create(); + $query = $db->getDbAdapter()->select() + ->from($dummy->getTableName()) + ->where('object_type = ?', $objectType); + } else { + $query = null; + } + $result = []; + $resolved = $this->getSetting('resolved') === 'y'; + foreach ($class::loadAllByType($objectClass, $db, $query) as $object) { + $result[] = $object->toPlainObject($resolved); + } + if ($objectClass === 'zone') { + $this->enrichZonesWithDeploymentZone($result); + } + return $result; + } + + protected function enrichZonesWithDeploymentZone(&$zones) + { + $masterZone = $this->db()->getMasterZoneName(); + foreach ($zones as $zone) { + $zone->is_master_zone = $zone->object_name === $masterZone; + } + } + + public static function addSettingsFormFields(QuickForm $form) + { + /** @var ImportSourceForm $form */ + Util::addDbResourceFormElement($form, 'resource'); + $form->getElement('resource') + ->setValue(Config::module('director')->get('db', 'resource')); + $form->addElement('select', 'object_class', [ + 'label' => $form->translate('Director Object'), + 'multiOptions' => [ + 'host' => $form->translate('Host'), + 'endpoint' => $form->translate('Endpoint'), + 'zone' => $form->translate('Zone'), + ], + 'required' => true, + ]); + $form->addElement('select', 'object_type', [ + 'label' => $form->translate('Object Type'), + 'multiOptions' => [ + null => $form->translate('All Object Types'), + 'object' => $form->translate('Objects'), + 'template' => $form->translate('Templates'), + 'external_object' => $form->translate('External Objects'), + 'apply' => $form->translate('Apply Rules'), + ], + ]); + + /** @var $form \Icinga\Module\Director\Web\Form\DirectorObjectForm */ + $form->addBoolean('resolved', [ + 'label' => $form->translate('Resolved'), + ], 'n'); + + return $form; + } + + protected function db() + { + if ($this->db === null) { + $this->db = Db::fromResourceName($this->settings['resource']); + } + + return $this->db; + } + + public function listColumns() + { + $rows = $this->fetchData(); + $columns = []; + + foreach ($rows as $object) { + foreach (array_keys((array) $object) as $column) { + if (! isset($columns[$column])) { + $columns[] = $column; + } + } + } + + return $columns; + } +} diff --git a/library/Director/Import/ImportSourceLdap.php b/library/Director/Import/ImportSourceLdap.php new file mode 100644 index 0000000..4518565 --- /dev/null +++ b/library/Director/Import/ImportSourceLdap.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Icinga\Data\ResourceFactory; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Form\QuickForm; + +class ImportSourceLdap extends ImportSourceHook +{ + protected $connection; + + public function fetchData() + { + $columns = $this->listColumns(); + $query = $this->connection() + ->select() + ->setUsePagedResults() + ->from($this->settings['objectclass'], $columns); + + if ($base = $this->settings['base']) { + $query->setBase($base); + } + if ($filter = $this->settings['filter']) { + $query->setNativeFilter($filter); + } + + if (in_array('dn', $columns)) { + $result = $query->fetchAll(); + foreach ($result as $dn => $row) { + $row->dn = $dn; + } + + return $result; + } else { + return $query->fetchAll(); + } + } + + public function listColumns() + { + return preg_split('/,\s*/', $this->settings['query'], -1, PREG_SPLIT_NO_EMPTY); + } + + public static function addSettingsFormFields(QuickForm $form) + { + Util::addLDAPResourceFormElement($form, 'resource'); + $form->addElement('text', 'base', array( + 'label' => $form->translate('LDAP Search Base'), + 'description' => $form->translate( + 'Your LDAP search base. Often something like OU=Users,OU=HQ,DC=your,DC=company,DC=tld' + ) + )); + $form->addElement('text', 'objectclass', array( + 'label' => $form->translate('Object class'), + 'description' => $form->translate( + 'An object class to search for. Might be "user", "group", "computer" or similar' + ) + )); + $form->addElement('text', 'filter', array( + 'label' => 'LDAP filter', + 'description' => $form->translate( + 'A custom LDAP filter to use in addition to the object class. This allows' + . ' for a lot of flexibility but requires LDAP filter skills. Simple filters' + . ' might look as follows: operatingsystem=*server*' + ) + )); + $form->addElement('textarea', 'query', array( + 'label' => $form->translate('Properties'), + 'description' => $form->translate( + 'The LDAP properties that should be fetched. This is required to be a' + . ' comma-separated list like: "cn, dnshostname, operatingsystem, sAMAccountName"' + ), + 'spellcheck' => 'false', + 'required' => true, + 'rows' => 5, + )); + return $form; + } + + protected function connection() + { + if ($this->connection === null) { + $this->connection = ResourceFactory::create($this->settings['resource']); + } + + return $this->connection; + } +} diff --git a/library/Director/Import/ImportSourceRestApi.php b/library/Director/Import/ImportSourceRestApi.php new file mode 100644 index 0000000..dc772e1 --- /dev/null +++ b/library/Director/Import/ImportSourceRestApi.php @@ -0,0 +1,380 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\RestApi\RestApiClient; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; + +class ImportSourceRestApi extends ImportSourceHook +{ + public function getName() + { + return 'REST API'; + } + + public function fetchData() + { + $result = $this->getRestApi()->get( + $this->getUrl(), + null, + $this->buildHeaders() + ); + $result = $this->extractProperty($result); + + return (array) $result; + } + + public function listColumns() + { + $rows = $this->fetchData(); + $columns = []; + + foreach ($rows as $object) { + foreach (array_keys((array) $object) as $column) { + if (! isset($columns[$column])) { + $columns[] = $column; + } + } + } + + return $columns; + } + + /** + * Extract result from a property specified + * + * A simple key, like "objects", will take the final result from key objects + * + * If you have a deeper key like "objects" under the key "results", specify this as "results.objects". + * + * When a key of the JSON object contains a literal ".", this can be escaped as + * + * @param $result + * + * @return mixed + */ + protected function extractProperty($result) + { + $property = $this->getSetting('extract_property'); + if (! $property) { + return $result; + } + + $parts = preg_split('~(?<!\\\\)\.~', $property); + + // iterate over parts of the attribute path + $data = $result; + foreach ($parts as $part) { + // un-escape any dots + $part = preg_replace('~\\\\.~', '.', $part); + + if (property_exists($data, $part)) { + $data = $data->$part; + } else { + throw new \RuntimeException(sprintf( + 'Result has no "%s" property. Available keys: %s', + $part, + implode(', ', array_keys((array) $data)) + )); + } + } + + return $data; + } + + protected function buildHeaders() + { + $headers = []; + + $text = $this->getSetting('headers', ''); + foreach (preg_split('~\r?\n~', $text, -1, PREG_SPLIT_NO_EMPTY) as $header) { + $header = trim($header); + $parts = preg_split('~\s*:\s*~', $header, 2); + if (count($parts) < 2) { + throw new InvalidPropertyException('Could not parse header: "%s"', $header); + } + + $headers[$parts[0]] = $parts[1]; + } + + return $headers; + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + static::addScheme($form); + static::addSslOptions($form); + static::addUrl($form); + static::addResultProperty($form); + static::addAuthentication($form); + static::addHeader($form); + static::addProxy($form); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addScheme(QuickForm $form) + { + $form->addElement('select', 'scheme', [ + 'label' => $form->translate('Protocol'), + 'description' => $form->translate( + 'Whether to use encryption when talking to the REST API' + ), + 'multiOptions' => [ + 'HTTPS' => $form->translate('HTTPS (strongly recommended)'), + 'HTTP' => $form->translate('HTTP (this is plaintext!)'), + ], + 'class' => 'autosubmit', + 'value' => 'HTTPS', + 'required' => true, + ]); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addHeader(QuickForm $form) + { + $form->addElement('textarea', 'headers', [ + 'label' => $form->translate('HTTP Header'), + 'description' => implode(' ', [ + $form->translate('Additional headers for the HTTP request.'), + $form->translate('Specify headers in text format "Header: Value", each header on a new line.'), + ]), + 'class' => 'preformatted', + 'rows' => 4, + ]); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addSslOptions(QuickForm $form) + { + $ssl = ! ($form->getSentOrObjectSetting('scheme', 'HTTPS') === 'HTTP'); + + if ($ssl) { + static::addBoolean($form, 'ssl_verify_peer', [ + 'label' => $form->translate('Verify Peer'), + 'description' => $form->translate( + 'Whether we should check that our peer\'s certificate has' + . ' been signed by a trusted CA. This is strongly recommended.' + ) + ], 'y'); + static::addBoolean($form, 'ssl_verify_host', [ + 'label' => $form->translate('Verify Host'), + 'description' => $form->translate( + 'Whether we should check that the certificate matches the' + . 'configured host' + ) + ], 'y'); + } + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addUrl(QuickForm $form) + { + $form->addElement('text', 'url', [ + 'label' => 'REST API URL', + 'description' => $form->translate( + 'Something like https://api.example.com/rest/v2/objects' + ), + 'required' => true, + ]); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addResultProperty(QuickForm $form) + { + $form->addElement('text', 'extract_property', [ + 'label' => 'Extract property', + 'description' => implode("\n", [ + $form->translate('Often the expected result is provided in a property like "objects".' + . ' Please specify this if required.'), + $form->translate('Also deeper keys can be specific by a dot-notation:'), + '"result.objects", "key.deeper_key.very_deep"', + $form->translate('Literal dots in a key name can be written in the escape notation:'), + '"key\.with\.dots"', + ]) + ]); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addAuthentication(QuickForm $form) + { + $form->addElement('text', 'username', [ + 'label' => $form->translate('Username'), + 'description' => $form->translate( + 'Will be used to authenticate against your REST API' + ), + ]); + + $form->addElement('storedPassword', 'password', [ + 'label' => $form->translate('Password'), + ]); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + protected static function addProxy(QuickForm $form) + { + $form->addElement('select', 'proxy_type', [ + 'label' => $form->translate('Proxy'), + 'description' => $form->translate( + 'In case your API is only reachable through a proxy, please' + . ' choose it\'s protocol right here' + ), + 'multiOptions' => $form->optionalEnum([ + 'HTTP' => $form->translate('HTTP proxy'), + 'SOCKS5' => $form->translate('SOCKS5 proxy'), + ]), + 'class' => 'autosubmit' + ]); + + $proxyType = $form->getSentOrObjectSetting('proxy_type'); + + if ($proxyType) { + $form->addElement('text', 'proxy', [ + 'label' => $form->translate('Proxy Address'), + 'description' => $form->translate( + 'Hostname, IP or <host>:<port>' + ), + 'required' => true, + ]); + if ($proxyType === 'HTTP') { + $form->addElement('text', 'proxy_user', [ + 'label' => $form->translate('Proxy Username'), + 'description' => $form->translate( + 'In case your proxy requires authentication, please' + . ' configure this here' + ), + ]); + + $passRequired = strlen($form->getSentOrObjectSetting('proxy_user')) > 0; + + $form->addElement('storedPassword', 'proxy_pass', [ + 'label' => $form->translate('Proxy Password'), + 'required' => $passRequired + ]); + } + } + } + + protected function getUrl() + { + $url = $this->getSetting('url'); + $parts = \parse_url($url); + if (isset($parts['path'])) { + $path = $parts['path']; + } else { + $path = '/'; + } + + if (isset($parts['query'])) { + $url = "$path?" . $parts['query']; + } else { + $url = $path; + } + + return $url; + } + + protected function getRestApi() + { + $url = $this->getSetting('url'); + $parts = \parse_url($url); + if (isset($parts['host'])) { + $host = $parts['host']; + } else { + throw new InvalidArgumentException("URL '$url' has no host"); + } + + $api = new RestApiClient( + $host, + $this->getSetting('username'), + $this->getSetting('password') + ); + + $api->setScheme($this->getSetting('scheme')); + if (isset($parts['port'])) { + $api->setPort($parts['port']); + } + + if ($api->getScheme() === 'HTTPS') { + if ($this->getSetting('ssl_verify_peer', 'y') === 'n') { + $api->disableSslPeerVerification(); + } + if ($this->getSetting('ssl_verify_host', 'y') === 'n') { + $api->disableSslHostVerification(); + } + } + + if ($proxy = $this->getSetting('proxy')) { + if ($proxyType = $this->getSetting('proxy_type')) { + $api->setProxy($proxy, $proxyType); + } else { + $api->setProxy($proxy); + } + + if ($user = $this->getSetting('proxy_user')) { + $api->setProxyAuth($user, $this->getSetting('proxy_pass')); + } + } + + return $api; + } + + /** + * @param QuickForm $form + * @param string $key + * @param array $options + * @param string|null $default + * @throws \Zend_Form_Exception + */ + protected static function addBoolean(QuickForm $form, $key, $options, $default = null) + { + if ($default === null) { + $form->addElement('OptionalYesNo', $key, $options); + } else { + $form->addElement('YesNo', $key, $options); + $form->getElement($key)->setValue($default); + } + } + + /** + * @param QuickForm $form + * @param string $key + * @param string $label + * @param string $description + * @throws \Zend_Form_Exception + */ + protected static function optionalBoolean(QuickForm $form, $key, $label, $description) + { + static::addBoolean($form, $key, [ + 'label' => $label, + 'description' => $description + ]); + } +} diff --git a/library/Director/Import/ImportSourceSql.php b/library/Director/Import/ImportSourceSql.php new file mode 100644 index 0000000..b08a3f3 --- /dev/null +++ b/library/Director/Import/ImportSourceSql.php @@ -0,0 +1,70 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use gipfl\Web\Widget\Hint; +use Icinga\Data\Db\DbConnection; +use Icinga\Module\Director\Forms\ImportSourceForm; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Form\Filter\QueryColumnsFromSql; +use Icinga\Module\Director\Web\Form\QuickForm; +use ipl\Html\Html; + +class ImportSourceSql extends ImportSourceHook +{ + protected $db; + + public function fetchData() + { + return $this->db()->fetchAll($this->settings['query']); + } + + public function listColumns() + { + if ($columns = $this->getSetting('column_cache')) { + return explode(', ', $columns); + } else { + return array_keys((array) current($this->fetchData())); + } + } + + public static function addSettingsFormFields(QuickForm $form) + { + /** @var ImportSourceForm $form */ + Util::addDbResourceFormElement($form, 'resource'); + /** @var ImportSource $current */ + $current = $form->getObject(); + + $form->addElement('textarea', 'query', [ + 'label' => $form->translate('DB Query'), + 'required' => true, + 'rows' => 15, + ]); + $form->addElement('hidden', 'column_cache', [ + 'value' => '', + 'filters' => [new QueryColumnsFromSql($form)], + 'required' => true + ]); + if ($current) { + if ($columns = $current->getSetting('column_cache')) { + $form->addHtmlHint('Columns: ' . $columns); + } else { + $form->addHtmlHint(Hint::warning($form->translate( + 'Please click "Store" once again to determine query columns' + ))); + } + } + return $form; + } + + protected function db() + { + if ($this->db === null) { + $this->db = DbConnection::fromResourceName($this->settings['resource'])->getDbAdapter(); + } + + return $this->db; + } +} diff --git a/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php b/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php new file mode 100644 index 0000000..9f0e8ab --- /dev/null +++ b/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Import\PurgeStrategy; + +use Icinga\Module\Director\Import\SyncUtils; +use Icinga\Module\Director\Objects\ImportRun; +use Icinga\Module\Director\Objects\ImportSource; + +class ImportRunBasedPurgeStrategy extends PurgeStrategy +{ + public function listObjectsToPurge() + { + $remove = array(); + + foreach ($this->getSyncRule()->fetchInvolvedImportSources() as $source) { + $remove += $this->checkImportSource($source); + } + + return $remove; + } + + protected function getLastSync() + { + return strtotime($this->getSyncRule()->getLastSyncTimestamp()); + } + + // TODO: NAMING! + protected function checkImportSource(ImportSource $source) + { + if (null === ($lastSync = $this->getLastSync())) { + // No last sync, nothing to purge + return array(); + } + + $runA = $source->fetchLastRunBefore($lastSync); + if ($runA === null) { + // Nothing to purge for this source + return array(); + } + + $runB = $source->fetchLastRun(); + if ($runA->rowset_checksum === $runB->rowset_checksum) { + // Same source data, nothing to purge + return array(); + } + + return $this->listKeysRemovedBetween($runA, $runB); + } + + public function listKeysRemovedBetween(ImportRun $runA, ImportRun $runB) + { + $rule = $this->getSyncRule(); + $db = $rule->getDb(); + + $selectA = $runA->prepareImportedObjectQuery(); + $selectB = $runB->prepareImportedObjectQuery(); + + $query = $db->select()->from( + array('a' => $selectA), + 'a.object_name' + )->where('a.object_name NOT IN (?)', $selectB); + + $result = $db->fetchCol($query); + + if (empty($result)) { + return array(); + } + + if ($rule->hasCombinedKey()) { + $pattern = $rule->getSourceKeyPattern(); + $columns = SyncUtils::getRootVariables( + SyncUtils::extractVariableNames($pattern) + ); + $resultForCombinedKey = array(); + foreach (array_chunk($result, 1000) as $keys) { + $rows = $runA->fetchRows($columns, null, $keys); + foreach ($rows as $row) { + $resultForCombinedKey[] = SyncUtils::fillVariables($pattern, $row); + } + } + $result = $resultForCombinedKey; + } + + if (empty($result)) { + return array(); + } + + return array_combine($result, $result); + } +} diff --git a/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php b/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php new file mode 100644 index 0000000..3da8d4f --- /dev/null +++ b/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php @@ -0,0 +1,11 @@ +<?php + +namespace Icinga\Module\Director\Import\PurgeStrategy; + +class PurgeNothingPurgeStrategy extends PurgeStrategy +{ + public function listObjectsToPurge() + { + return array(); + } +} diff --git a/library/Director/Import/PurgeStrategy/PurgeStrategy.php b/library/Director/Import/PurgeStrategy/PurgeStrategy.php new file mode 100644 index 0000000..ffbe14f --- /dev/null +++ b/library/Director/Import/PurgeStrategy/PurgeStrategy.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Import\PurgeStrategy; + +use Icinga\Module\Director\Objects\SyncRule; + +abstract class PurgeStrategy +{ + private $rule; + + public function __construct(SyncRule $rule) + { + $this->rule = $rule; + } + + protected function getSyncRule() + { + return $this->rule; + } + + abstract public function listObjectsToPurge(); + + /** + * @return PurgeStrategy + */ + public static function load($name, SyncRule $rule) + { + $class = __NAMESPACE__ . '\\' . $name . 'PurgeStrategy'; + return new $class($rule); + } +} diff --git a/library/Director/Import/Sync.php b/library/Director/Import/Sync.php new file mode 100644 index 0000000..8fea46c --- /dev/null +++ b/library/Director/Import/Sync.php @@ -0,0 +1,942 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Exception; +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\BranchSupport; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Objects\HostGroupMembershipResolver; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaHostGroup; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\SyncProperty; +use Icinga\Module\Director\Objects\SyncRule; +use Icinga\Module\Director\Objects\SyncRun; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use InvalidArgumentException; +use RuntimeException; + +class Sync +{ + /** @var SyncRule */ + protected $rule; + + /** @var Db */ + protected $db; + + /** @var array Related ImportSource objects */ + protected $sources; + + /** @var array Source columns we want to fetch from our sources */ + protected $sourceColumns; + + /** @var array Imported data */ + protected $imported; + + /** @var IcingaObject[] Objects to work with */ + protected $objects; + + /** @var array<mixed, array<int, string>> key => [property, property]*/ + protected $setNull = []; + + /** @var bool Whether we already prepared your sync */ + protected $isPrepared = false; + + /** @var bool Whether we applied strtolower() to existing object keys */ + protected $usedLowerCasedKeys = false; + + protected $modify = []; + + protected $remove = []; + + protected $create = []; + + protected $errors = []; + + /** @var SyncProperty[] */ + protected $syncProperties; + + protected $replaceVars = false; + + protected $hasPropertyDisabled = false; + + protected $serviceOverrideKeyName; + + /** + * @var SyncRun + */ + protected $run; + + protected $runStartTime; + + /** @var Filter[] */ + protected $columnFilters = []; + + /** @var HostGroupMembershipResolver|bool */ + protected $hostGroupMembershipResolver; + + /** @var ?DbObjectStore */ + protected $store; + + /** + * @param SyncRule $rule + * @param ?DbObjectStore $store + */ + public function __construct(SyncRule $rule, DbObjectStore $store = null) + { + $this->rule = $rule; + $this->db = $rule->getConnection(); + $this->store = $store; + } + + /** + * Whether the given sync rule would apply modifications + * + * @return boolean + * @throws Exception + */ + public function hasModifications() + { + return count($this->getExpectedModifications()) > 0; + } + + /** + * Retrieve modifications a given SyncRule would apply + * + * @return array Array of IcingaObject elements + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function getExpectedModifications() + { + $modified = []; + $objects = $this->prepare(); + $updateOnly = $this->rule->get('update_policy') === 'update-only'; + $allowCreate = ! $updateOnly; + foreach ($objects as $object) { + if ($object->hasBeenModified()) { + if ($allowCreate || $object->hasBeenLoadedFromDb()) { + $modified[] = $object; + } + } elseif (! $updateOnly && $object->shouldBeRemoved()) { + $modified[] = $object; + } + } + + return $modified; + } + + /** + * Transform the given value to an array + * + * @param array|string|null $value + * + * @return array + */ + protected function wantArray($value) + { + if (is_array($value)) { + return $value; + } elseif ($value === null) { + return []; + } else { + return [$value]; + } + } + + /** + * Raise PHP resource limits + * + * @return self; + */ + protected function raiseLimits() + { + MemoryLimit::raiseTo('1024M'); + ini_set('max_execution_time', 0); + + return $this; + } + + /** + * Initialize run summary measurements + * + * @return self; + */ + protected function startMeasurements() + { + $this->run = SyncRun::start($this->rule); + $this->runStartTime = microtime(true); + Benchmark::measure('Starting sync'); + return $this; + } + + /** + * Fetch the configured properties involved in this sync + * + * @return self + */ + protected function fetchSyncProperties() + { + $this->syncProperties = $this->rule->getSyncProperties(); + foreach ($this->syncProperties as $key => $prop) { + $destinationField = $prop->get('destination_field'); + if ($destinationField === 'vars' && $prop->get('merge_policy') === 'override') { + $this->replaceVars = true; + } + + if ($destinationField === 'disabled') { + $this->hasPropertyDisabled = true; + } + + if ($prop->get('filter_expression') === null || strlen($prop->get('filter_expression')) === 0) { + continue; + } + + $this->columnFilters[$key] = Filter::fromQueryString( + $prop->get('filter_expression') + ); + } + + return $this; + } + + protected function rowMatchesPropertyFilter($row, $key) + { + if (!array_key_exists($key, $this->columnFilters)) { + return true; + } + + return $this->columnFilters[$key]->matches($row); + } + + /** + * Instantiates all related ImportSource objects + * + * @return self + * @throws \Icinga\Exception\NotFoundError + */ + protected function prepareRelatedImportSources() + { + $this->sources = []; + foreach ($this->syncProperties as $p) { + $id = $p->get('source_id'); + if (! array_key_exists($id, $this->sources)) { + $this->sources[$id] = ImportSource::loadWithAutoIncId( + (int) $id, + $this->db + ); + } + } + + return $this; + } + + /** + * Prepare the source columns we want to fetch + * + * @return self + */ + protected function prepareSourceColumns() + { + // $fieldMap = []; + $this->sourceColumns = []; + + foreach ($this->syncProperties as $p) { + $sourceId = $p->get('source_id'); + if (! array_key_exists($sourceId, $this->sourceColumns)) { + $this->sourceColumns[$sourceId] = []; + } + + foreach (SyncUtils::extractVariableNames($p->get('source_expression')) as $varname) { + $this->sourceColumns[$sourceId][$varname] = $varname; + // -> ? $fieldMap[ + } + } + + return $this; + } + + /** + * Fetch latest imported data rows from all involved import sources + * @return Sync + * @throws \Icinga\Exception\NotFoundError + */ + protected function fetchImportedData() + { + Benchmark::measure('Begin loading imported data'); + if ($this->rule->get('object_type') === 'host') { + $this->serviceOverrideKeyName = $this->db->settings()->override_services_varname; + } + + $this->imported = []; + + $sourceKeyPattern = $this->rule->getSourceKeyPattern(); + $combinedKey = $this->rule->hasCombinedKey(); + + foreach ($this->sources as $source) { + /** @var ImportSource $source */ + $sourceId = $source->get('id'); + + // Provide an alias column for our key. TODO: double-check this! + $key = $source->key_column; + $this->sourceColumns[$sourceId][$key] = $key; + $run = $source->fetchLastRun(true); + + $usedColumns = SyncUtils::getRootVariables($this->sourceColumns[$sourceId]); + + $filterColumns = []; + foreach ($this->columnFilters as $filter) { + foreach ($filter->listFilteredColumns() as $column) { + $filterColumns[$column] = $column; + } + } + if (($ruleFilter = $this->rule->filter()) !== null) { + foreach ($ruleFilter->listFilteredColumns() as $column) { + $filterColumns[$column] = $column; + } + } + + if (! empty($filterColumns)) { + foreach (SyncUtils::getRootVariables($filterColumns) as $column) { + $usedColumns[$column] = $column; + } + } + Benchmark::measure(sprintf('Done pre-processing columns for source %s', $source->source_name)); + + $rows = $run->fetchRows($usedColumns); + Benchmark::measure(sprintf('Fetched source %s', $source->source_name)); + + $this->imported[$sourceId] = []; + foreach ($rows as $row) { + if ($combinedKey) { + $key = SyncUtils::fillVariables($sourceKeyPattern, $row); + if ($this->usedLowerCasedKeys) { + $key = strtolower($key); + } + + if (array_key_exists($key, $this->imported[$sourceId])) { + throw new InvalidArgumentException(sprintf( + 'Trying to import row "%s" (%s) twice: %s VS %s', + $key, + $sourceKeyPattern, + json_encode($this->imported[$sourceId][$key]), + json_encode($row) + )); + } + } else { + if (! property_exists($row, $key)) { + throw new InvalidArgumentException(sprintf( + 'There is no key column "%s" in this row from "%s": %s', + $key, + $source->source_name, + json_encode($row) + )); + } + } + + if (! $this->rule->matches($row)) { + continue; + } + + if ($combinedKey) { + $this->imported[$sourceId][$key] = $row; + } else { + if ($this->usedLowerCasedKeys) { + $this->imported[$sourceId][strtolower($row->$key)] = $row; + } else { + $this->imported[$sourceId][$row->$key] = $row; + } + } + } + + unset($rows); + } + + Benchmark::measure('Done loading imported data'); + + return $this; + } + + /** + * TODO: This is rubbish, we need to filter at fetch time + */ + protected function removeForeignListEntries() + { + $listId = null; + foreach ($this->syncProperties as $prop) { + if ($prop->get('destination_field') === 'list_id') { + $listId = (int) $prop->get('source_expression'); + } + } + + if ($listId === null) { + throw new InvalidArgumentException( + 'Cannot sync datalist entry without list_id' + ); + } + + $no = []; + foreach ($this->objects as $k => $o) { + if ((int) $o->get('list_id') !== $listId) { + $no[] = $k; + } + } + + foreach ($no as $k) { + unset($this->objects[$k]); + } + } + + /** + * @return $this + */ + protected function loadExistingObjects() + { + Benchmark::measure('Begin loading existing objects'); + + $ruleObjectType = $this->rule->get('object_type'); + $useLowerCaseKeys = $ruleObjectType !== 'datalistEntry'; + // TODO: Make object_type (template, object...) and object_name mandatory? + if ($this->rule->hasCombinedKey()) { + $this->objects = []; + $destinationKeyPattern = $this->rule->getDestinationKeyPattern(); + $table = DbObjectTypeRegistry::tableNameByType($ruleObjectType); + if ($this->store && BranchSupport::existsForTableName($table)) { + $objects = $this->store->loadAll($table); + } else { + $objects = IcingaObject::loadAllByType($ruleObjectType, $this->db); + } + + foreach ($objects as $object) { + if ($object instanceof IcingaService) { + if (strstr($destinationKeyPattern, '${host}') + && $object->get('host_id') === null + ) { + continue; + } elseif (strstr($destinationKeyPattern, '${service_set}') + && $object->get('service_set_id') === null + ) { + continue; + } + } + + $key = SyncUtils::fillVariables( + $destinationKeyPattern, + $object + ); + if ($useLowerCaseKeys) { + $key = strtolower($key); + } + + if (array_key_exists($key, $this->objects)) { + throw new InvalidArgumentException(sprintf( + 'Combined destination key "%s" is not unique, got "%s" twice', + $destinationKeyPattern, + $key + )); + } + + $this->objects[$key] = $object; + } + } else { + if ($this->store) { + $objects = $this->store->loadAll(DbObjectTypeRegistry::tableNameByType($ruleObjectType), 'object_name'); + } else { + $objects = IcingaObject::loadAllByType($ruleObjectType, $this->db); + } + + if ($useLowerCaseKeys) { + $this->objects = []; + foreach ($objects as $key => $object) { + $this->objects[strtolower($key)] = $object; + } + } else { + $this->objects = $objects; + } + } + + $this->usedLowerCasedKeys = $useLowerCaseKeys; + // TODO: should be obsoleted by a better "loadFiltered" method + if ($ruleObjectType === 'datalistEntry') { + $this->removeForeignListEntries(); + } + + Benchmark::measure('Done loading existing objects'); + + return $this; + } + + /** + * @return array + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function prepareNewObjects() + { + $objects = []; + $ruleObjectType = $this->rule->get('object_type'); + + foreach ($this->sources as $source) { + $sourceId = $source->id; + $keyColumn = $source->get('key_column'); + + foreach ($this->imported[$sourceId] as $key => $row) { + // Workaround: $a["10"] = "val"; -> array_keys($a) = [(int) 10] + $key = (string) $key; + $originalKey = $row->$keyColumn; + if ($this->usedLowerCasedKeys) { + $key = strtolower($key); + } + if (! array_key_exists($key, $objects)) { + // Safe default values for object_type and object_name + if ($ruleObjectType === 'datalistEntry') { + $props = []; + } else { + $props = [ + 'object_type' => 'object', + 'object_name' => $originalKey, + ]; + } + + $objects[$key] = IcingaObject::createByType( + $ruleObjectType, + $props, + $this->db + ); + } + + $object = $objects[$key]; + $this->prepareNewObject($row, $object, $key, $sourceId); + } + } + + return $objects; + } + + /** + * @param $row + * @param DbObject $object + * @param $sourceId + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function prepareNewObject($row, DbObject $object, $objectKey, $sourceId) + { + foreach ($this->syncProperties as $propertyKey => $p) { + if ($p->get('source_id') !== $sourceId) { + continue; + } + + if (! $this->rowMatchesPropertyFilter($row, $propertyKey)) { + continue; + } + + $prop = $p->get('destination_field'); + $val = SyncUtils::fillVariables($p->get('source_expression'), $row); + + if ($object instanceof IcingaObject) { + if ($prop === 'import') { + if ($val !== null) { + $object->imports()->add($val); + } + } elseif ($prop === 'groups') { + if ($val !== null) { + $object->groups()->add($val); + } + } elseif (substr($prop, 0, 5) === 'vars.') { + $varName = substr($prop, 5); + if (substr($varName, -2) === '[]') { + $varName = substr($varName, 0, -2); + $current = $this->wantArray($object->vars()->$varName); + $object->vars()->$varName = array_merge( + $current, + $this->wantArray($val) + ); + } else { + if ($val === null) { + $this->setNull[$objectKey][$prop] = $prop; + } else { + unset($this->setNull[$objectKey][$prop]); + $object->vars()->$varName = $val; + } + } + } else { + if ($val === null) { + $this->setNull[$objectKey][$prop] = $prop; + } else { + unset($this->setNull[$objectKey][$prop]); + $object->set($prop, $val); + } + } + } else { + if ($val === null) { + $this->setNull[$objectKey][$prop] = $prop; + } else { + unset($this->setNull[$objectKey][$prop]); + $object->set($prop, $val); + } + } + } + } + + /** + * @return $this + */ + protected function deferResolvers() + { + if (in_array($this->rule->get('object_type'), ['host', 'hostgroup'])) { + $resolver = $this->getHostGroupMembershipResolver(); + $resolver->defer()->setUseTransactions(false); + } + + return $this; + } + + /** + * @param DbObject $object + * @return $this + */ + protected function setResolver($object) + { + if (! ($object instanceof IcingaHost || $object instanceof IcingaHostGroup)) { + return $this; + } + if ($resolver = $this->getHostGroupMembershipResolver()) { + $object->setHostGroupMembershipResolver($resolver); + } + + return $this; + } + + /** + * @return $this + * @throws \Zend_Db_Adapter_Exception + */ + protected function notifyResolvers() + { + if ($resolver = $this->getHostGroupMembershipResolver()) { + $resolver->refreshDb(true); + } + + return $this; + } + + /** + * @return bool|HostGroupMembershipResolver + */ + protected function getHostGroupMembershipResolver() + { + if ($this->hostGroupMembershipResolver === null) { + if (in_array( + $this->rule->get('object_type'), + ['host', 'hostgroup'] + )) { + $this->hostGroupMembershipResolver = new HostGroupMembershipResolver( + $this->db + ); + } else { + $this->hostGroupMembershipResolver = false; + } + } + + return $this->hostGroupMembershipResolver; + } + + /** + * Evaluates a SyncRule and returns a list of modified objects + * + * TODO: Split this into smaller methods + * + * @return DbObject|IcingaObject[] List of modified IcingaObjects + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function prepare() + { + if ($this->isPrepared) { + return $this->objects; + } + + $this->raiseLimits() + ->startMeasurements() + ->prepareCache() + ->fetchSyncProperties() + ->prepareRelatedImportSources() + ->prepareSourceColumns() + ->loadExistingObjects() + ->fetchImportedData() + ->deferResolvers(); + + Benchmark::measure('Begin preparing updated objects'); + $newObjects = $this->prepareNewObjects(); + + Benchmark::measure('Ready to process objects'); + /** @var DbObject|IcingaObject $object */ + foreach ($newObjects as $key => $object) { + $this->processObject($key, $object); + } + + Benchmark::measure('Modified objects are ready, applying purge strategy'); + $noAction = []; + $purgeAction = $this->rule->get('purge_action'); + foreach ($this->rule->purgeStrategy()->listObjectsToPurge() as $key) { + $key = strtolower($key); + if (array_key_exists($key, $newObjects)) { + // Object has been touched, do not delete + continue; + } + + if (array_key_exists($key, $this->objects)) { + $object = $this->objects[$key]; + if (! $object->hasBeenModified()) { + switch ($purgeAction) { + case 'delete': + $object->markForRemoval(); + break; + case 'disable': + $object->set('disabled', 'y'); + break; + default: + throw new RuntimeException( + "Unsupported purge action: '$purgeAction'" + ); + } + } + } + } + + Benchmark::measure('Done marking objects for purge'); + + foreach ($this->objects as $key => $object) { + if (! $object->hasBeenModified() && ! $object->shouldBeRemoved()) { + $noAction[] = $key; + } + } + + foreach ($noAction as $key) { + unset($this->objects[$key]); + } + + $this->isPrepared = true; + + Benchmark::measure('Done preparing objects'); + + return $this->objects; + } + + /** + * @param $key + * @param DbObject|IcingaObject $object + * @throws \Icinga\Exception\NotFoundError + */ + protected function processObject($key, $object) + { + if (array_key_exists($key, $this->objects)) { + $this->refreshObject($key, $object); + } else { + $this->addNewObject($key, $object); + } + } + + /** + * @param $key + * @param DbObject|IcingaObject $object + * @throws \Icinga\Exception\NotFoundError + */ + protected function refreshObject($key, $object) + { + $policy = $this->rule->get('update_policy'); + + switch ($policy) { + case 'override': + if ($object instanceof IcingaHost + && !in_array('api_key', $this->rule->getSyncProperties()) + ) { + $this->objects[$key]->replaceWith($object, ['api_key']); + } else { + $this->objects[$key]->replaceWith($object); + } + break; + + case 'merge': + case 'update-only': + // TODO: re-evaluate merge settings. vars.x instead of + // just "vars" might suffice. + $this->objects[$key]->merge($object, $this->replaceVars); + if (! $this->hasPropertyDisabled && $object->hasProperty('disabled')) { + $this->objects[$key]->resetProperty('disabled'); + } + break; + + default: + // policy 'ignore', no action + } + + if ($policy === 'override' || $policy === 'merge') { + if ($object instanceof IcingaHost) { + $keyName = $this->serviceOverrideKeyName; + if (! $object->hasInitializedVars() || ! isset($object->vars()->$key)) { + $this->objects[$key]->vars()->restoreStoredVar($keyName); + } + } + } + + if (isset($this->setNull[$key])) { + foreach ($this->setNull[$key] as $property) { + $this->objects[$key]->set($property, null); + } + } + } + + /** + * @param $key + * @param DbObject|IcingaObject $object + */ + protected function addNewObject($key, $object) + { + $this->objects[$key] = $object; + } + + /** + * Runs a SyncRule and applies all resulting changes + * @return int + * @throws Exception + * @throws IcingaException + */ + public function apply() + { + Benchmark::measure('Begin applying objects'); + + $objects = $this->prepare(); + $db = $this->db; + $dba = $db->getDbAdapter(); + if (! $this->store) { // store has it's own transaction + $dba->beginTransaction(); + } + + $object = null; + $updateOnly = $this->rule->get('update_policy') === 'update-only'; + $allowCreate = ! $updateOnly; + + try { + $formerActivityChecksum = hex2bin( + $db->getLastActivityChecksum() + ); + $created = 0; + $modified = 0; + $deleted = 0; + // TODO: Count also failed ones, once we allow such + // $failed = 0; + foreach ($objects as $object) { + $this->setResolver($object); + if (! $updateOnly && $object->shouldBeRemoved()) { + if ($this->store) { + $this->store->delete($object); + } else { + $object->delete(); + } + $deleted++; + continue; + } + + if ($object->hasBeenModified()) { + $existing = $object->hasBeenLoadedFromDb(); + if ($existing) { + if ($this->store) { + $this->store->store($object); + } else { + $object->store($db); + } + $modified++; + } elseif ($allowCreate) { + if ($this->store) { + $this->store->store($object); + } else { + $object->store($db); + } + $created++; + } + } + } + + $runProperties = [ + 'objects_created' => $created, + 'objects_deleted' => $deleted, + 'objects_modified' => $modified, + ]; + + if ($created + $deleted + $modified > 0) { + // TODO: What if this has been the very first activity? + $runProperties['last_former_activity'] = $db->quoteBinary($formerActivityChecksum); + $runProperties['last_related_activity'] = $db->quoteBinary(hex2bin( + $db->getLastActivityChecksum() + )); + } + + $this->run->setProperties($runProperties); + if (!$this->store || !$this->store->getBranch()->isBranch()) { + $this->run->store(); + } + $this->notifyResolvers(); + if (! $this->store) { + $dba->commit(); + } + + // Store duration after commit, as the commit might take some time + $this->run->set('duration_ms', (int) round( + (microtime(true) - $this->runStartTime) * 1000 + )); + if (!$this->store || !$this->store->getBranch()->isBranch()) { + $this->run->store(); + } + + Benchmark::measure('Done applying objects'); + } catch (Exception $e) { + if (! $this->store) { + $dba->rollBack(); + } + + if ($object instanceof IcingaObject) { + throw new IcingaException( + 'Exception while syncing %s %s: %s', + get_class($object), + $object->getObjectName(), + $e->getMessage(), + $e + ); + } else { + throw $e; + } + } + + return $this->run->get('id'); + } + + protected function prepareCache() + { + if ($this->store) { + return $this; + } + PrefetchCache::initialize($this->db); + IcingaTemplateRepository::clear(); + + $ruleObjectType = $this->rule->get('object_type'); + + $dummy = IcingaObject::createByType($ruleObjectType); + if ($dummy instanceof IcingaObject) { + IcingaObject::prefetchAllRelationsByType($ruleObjectType, $this->db); + } + + return $this; + } +} diff --git a/library/Director/Import/SyncUtils.php b/library/Director/Import/SyncUtils.php new file mode 100644 index 0000000..5528b2d --- /dev/null +++ b/library/Director/Import/SyncUtils.php @@ -0,0 +1,153 @@ +<?php + +namespace Icinga\Module\Director\Import; + +use Icinga\Module\Director\Data\Db\DbObject; +use InvalidArgumentException; + +class SyncUtils +{ + /** + * Extract variable names in the form ${var_name} from a given string + * + * @param string $string + * + * @return array List of variable names (without ${}) + */ + public static function extractVariableNames($string) + { + if (preg_match_all('/\${([^}]+)}/', $string, $m, PREG_PATTERN_ORDER)) { + return $m[1]; + } else { + return array(); + } + } + + /** + * Whether the given string contains variable names in the form ${var_name} + * + * @param string $string + * + * @return bool + */ + public static function hasVariables($string) + { + return preg_match('/\${([^}]+)}/', $string); + } + + /** + * Recursively extract a value from a nested structure + * + * For a $val looking like + * + * { 'vars' => { 'disk' => { 'sda' => { 'size' => '256G' } } } } + * + * and a key vars.disk.sda given as [ 'vars', 'disk', 'sda' ] this would + * return { size => '255GB' } + * + * @param string $val The value to extract data from + * @param array $keys A list of nested keys pointing to desired data + * + * @return mixed + */ + public static function getDeepValue($val, array $keys) + { + $key = array_shift($keys); + if (! property_exists($val, $key)) { + return null; + } + + if (empty($keys)) { + return $val->$key; + } + + return static::getDeepValue($val->$key, $keys); + } + + /** + * Return a specific value from a given row object + * + * Supports also keys pointing to nested structures like vars.disk.sda + * + * @param object $row stdClass object providing property values + * @param string $var Variable/property name + * + * @return mixed + */ + public static function getSpecificValue($row, $var) + { + if (strpos($var, '.') === false) { + if ($row instanceof DbObject) { + return $row->$var; + } + if (! property_exists($row, $var)) { + return null; + } + + return $row->$var; + } else { + $parts = explode('.', $var); + $main = array_shift($parts); + if (! property_exists($row, $main)) { + return null; + } + if ($row->$main === null) { + return null; + } + + if (! is_object($row->$main)) { + throw new InvalidArgumentException(sprintf( + 'Data is not nested, cannot access %s: %s', + $var, + var_export($row, 1) + )); + } + + return static::getDeepValue($row->$main, $parts); + } + } + + /** + * Fill variables in the given string pattern + * + * This replaces all occurrences of ${var_name} with the corresponding + * property $row->var_name of the given row object. Missing variables are + * replaced by an empty string. This works also fine in case there are + * multiple variables to be found in your string. + * + * @param string $string String with optional variables/placeholders + * @param object $row stdClass object providing property values + * + * @return string + */ + public static function fillVariables($string, $row) + { + if (preg_match('/^\${([^}]+)}$/', $string, $m)) { + return static::getSpecificValue($row, $m[1]); + } + + $func = function ($match) use ($row) { + return SyncUtils::getSpecificValue($row, $match[1]); + }; + + return preg_replace_callback('/\${([^}]+)}/', $func, $string); + } + + public static function getRootVariables($vars) + { + $res = array(); + foreach ($vars as $p) { + if (false === ($pos = strpos($p, '.')) || $pos === strlen($p) - 1) { + $res[] = $p; + } else { + $res[] = substr($p, 0, $pos); + } + } + + if (empty($res)) { + return array(); + } + + return array_combine($res, $res); + } +} diff --git a/library/Director/Job/ConfigJob.php b/library/Director/Job/ConfigJob.php new file mode 100644 index 0000000..fda3043 --- /dev/null +++ b/library/Director/Job/ConfigJob.php @@ -0,0 +1,75 @@ +<?php + +namespace Icinga\Module\Director\Job; + +use Icinga\Module\Director\Deployment\ConditionalConfigRenderer; +use Icinga\Module\Director\Deployment\ConditionalDeployment; +use Icinga\Module\Director\Deployment\DeploymentGracePeriod; +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class ConfigJob extends JobHook +{ + public function run() + { + $db = $this->db(); + $deployer = new ConditionalDeployment($db); + $renderer = new ConditionalConfigRenderer($db); + if ($grace = $this->getSetting('grace_period')) { + $deployer->setGracePeriod(new DeploymentGracePeriod((int) $grace, $db)); + } + if ($this->getSetting('force_generate') === 'y') { + $renderer->forceRendering(); + } + + $deployer->deploy($renderer->getConfig()); + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'force_generate', [ + 'label' => $form->translate('Force rendering'), + 'description' => $form->translate( + 'Whether rendering should be forced. If not enforced, this' + . ' job re-renders the configuration only when there have been' + . ' activities since the last rendered config' + ), + 'value' => 'n', + 'multiOptions' => [ + 'y' => $form->translate('Yes'), + 'n' => $form->translate('No'), + ] + ]); + + $form->addElement('select', 'deploy_when_changed', [ + 'label' => $form->translate('Deploy modified config'), + 'description' => $form->translate( + 'This allows you to immediately deploy a modified configuration' + ), + 'value' => 'n', + 'multiOptions' => [ + 'y' => $form->translate('Yes'), + 'n' => $form->translate('No'), + ] + ]); + + $form->addElement('text', 'grace_period', array( + 'label' => $form->translate('Grace period'), + 'description' => $form->translate( + 'When deploying configuration, wait at least this amount of' + . ' seconds unless the next deployment should take place' + ), + 'value' => 600, + )); + + return $form; + } + + public static function getDescription(QuickForm $form) + { + return $form->translate( + 'The Config job allows you to generate and eventually deploy your' + . ' Icinga 2 configuration' + ); + } +} diff --git a/library/Director/Job/HousekeepingJob.php b/library/Director/Job/HousekeepingJob.php new file mode 100644 index 0000000..9f3f596 --- /dev/null +++ b/library/Director/Job/HousekeepingJob.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Director\Job; + +use Icinga\Module\Director\Db\Housekeeping; +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class HousekeepingJob extends JobHook +{ + protected $housekeeping; + + public function run() + { + $this->housekeeping()->runAllTasks(); + } + + public static function getDescription(QuickForm $form) + { + return $form->translate( + 'The Housekeeping job provides various task that keep your Director' + . ' database fast and clean' + ); + } + + public function isPending() + { + return $this->housekeeping()->hasPendingTasks(); + } + + protected function housekeeping() + { + if ($this->housekeeping === null) { + $this->housekeeping = new Housekeeping($this->db()); + } + + return $this->housekeeping; + } +} diff --git a/library/Director/Job/ImportJob.php b/library/Director/Job/ImportJob.php new file mode 100644 index 0000000..5f2c81c --- /dev/null +++ b/library/Director/Job/ImportJob.php @@ -0,0 +1,122 @@ +<?php + +namespace Icinga\Module\Director\Job; + +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; + +class ImportJob extends JobHook +{ + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function run() + { + $db = $this->db(); + $id = $this->getSetting('source_id'); + if ($id === '__ALL__') { + foreach (ImportSource::loadAll($db) as $source) { + $this->runForSource($source); + } + } else { + $this->runForSource(ImportSource::loadWithAutoIncId($id, $db)); + } + } + + /** + * @return array + * @throws \Icinga\Exception\NotFoundError + */ + public function exportSettings() + { + $settings = parent::exportSettings(); + if (array_key_exists('source_id', $settings)) { + $id = $settings['source_id']; + if ($id !== '__ALL__') { + $settings['source'] = ImportSource::loadWithAutoIncId( + $id, + $this->db() + )->get('source_name'); + } + + unset($settings['source_id']); + } + + return $settings; + } + + /** + * @param ImportSource $source + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function runForSource(ImportSource $source) + { + if ($this->getSetting('run_import') === 'y') { + $source->runImport(); + } else { + $source->checkForChanges(); + } + } + + public static function getDescription(QuickForm $form) + { + return $form->translate( + 'The "Import" job allows to run import actions at regular intervals' + ); + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + $rules = self::enumImportSources($form); + + $form->addElement('select', 'source_id', array( + 'label' => $form->translate('Import source'), + 'description' => $form->translate( + 'Please choose your import source that should be executed.' + . ' You could create different schedules for different sources' + . ' or also opt for running all of them at once.' + ), + 'required' => true, + 'class' => 'autosubmit', + 'multiOptions' => $rules + )); + + $form->addElement('select', 'run_import', array( + 'label' => $form->translate('Run import'), + 'description' => $form->translate( + 'You could immediately apply eventual changes or just learn about them.' + . ' In case you do not want them to be applied immediately, defining a' + . ' job still makes sense. You will be made aware of available changes' + . ' in your Director GUI.' + ), + 'value' => 'n', + 'multiOptions' => array( + 'y' => $form->translate('Yes'), + 'n' => $form->translate('No'), + ) + )); + } + + protected static function enumImportSources(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb(); + $query = $db->select()->from( + 'import_source', + array('id', 'source_name') + )->order('source_name'); + + $res = $db->fetchPairs($query); + return array( + null => $form->translate('- please choose -'), + '__ALL__' => $form->translate('Run all imports at once') + ) + $res; + } +} diff --git a/library/Director/Job/SyncJob.php b/library/Director/Job/SyncJob.php new file mode 100644 index 0000000..0a5aa37 --- /dev/null +++ b/library/Director/Job/SyncJob.php @@ -0,0 +1,128 @@ +<?php + +namespace Icinga\Module\Director\Job; + +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Module\Director\Objects\SyncRule; + +class SyncJob extends JobHook +{ + protected $rule; + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function run() + { + $db = $this->db(); + $id = $this->getSetting('rule_id'); + if ($id === '__ALL__') { + foreach (SyncRule::loadAll($db) as $rule) { + $this->runForRule($rule); + } + } else { + $this->runForRule(SyncRule::loadWithAutoIncId((int) $id, $db)); + } + } + + /** + * @return array + * @throws \Icinga\Exception\NotFoundError + */ + public function exportSettings() + { + $settings = [ + 'apply_changes' => $this->getSetting('apply_changes') === 'y' + ]; + $id = $this->getSetting('rule_id'); + if ($id !== '__ALL__') { + $settings['rule'] = SyncRule::loadWithAutoIncId((int) $id, $this->db()) + ->get('rule_name'); + } + + return $settings; + } + + /** + * @param SyncRule $rule + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function runForRule(SyncRule $rule) + { + if ($this->getSetting('apply_changes') === 'y') { + $rule->applyChanges(); + } else { + $rule->checkForChanges(); + } + } + + public static function getDescription(QuickForm $form) + { + return $form->translate( + 'The "Sync" job allows to run sync actions at regular intervals' + ); + } + + /** + * @param QuickForm $form + * @return DirectorObjectForm|QuickForm + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $rules = self::enumSyncRules($form); + + $form->addElement('select', 'rule_id', array( + 'label' => $form->translate('Synchronization rule'), + 'description' => $form->translate( + 'Please choose your synchronization rule that should be executed.' + . ' You could create different schedules for different rules or also' + . ' opt for running all of them at once.' + ), + 'required' => true, + 'class' => 'autosubmit', + 'multiOptions' => $rules + )); + + $form->addElement('select', 'apply_changes', array( + 'label' => $form->translate('Apply changes'), + 'description' => $form->translate( + 'You could immediately apply eventual changes or just learn about them.' + . ' In case you do not want them to be applied immediately, defining a' + . ' job still makes sense. You will be made aware of available changes' + . ' in your Director GUI.' + ), + 'value' => 'n', + 'multiOptions' => array( + 'y' => $form->translate('Yes'), + 'n' => $form->translate('No'), + ) + )); + + if ((string) $form->getSentOrObjectValue('job_name') !== '') { + if (($ruleId = $form->getSentValue('rule_id')) && array_key_exists($ruleId, $rules)) { + $name = sprintf('Sync job: %s', $rules[$ruleId]); + $form->getElement('job_name')->setValue($name); + ///$form->getObject()->set('job_name', $name); + } + } + + return $form; + } + + protected static function enumSyncRules(QuickForm $form) + { + /** @var DirectorObjectForm $form */ + $db = $form->getDb(); + $query = $db->select()->from('sync_rule', array('id', 'rule_name'))->order('rule_name'); + $res = $db->fetchPairs($query); + return array( + null => $form->translate('- please choose -'), + '__ALL__' => $form->translate('Run all rules at once') + ) + $res; + } +} diff --git a/library/Director/KickstartHelper.php b/library/Director/KickstartHelper.php new file mode 100644 index 0000000..5010255 --- /dev/null +++ b/library/Director/KickstartHelper.php @@ -0,0 +1,555 @@ +<?php + +namespace Icinga\Module\Director; + +use Exception; +use Icinga\Application\Config; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Objects\IcingaApiUser; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaZone; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Core\RestApiClient; +use RuntimeException; + +class KickstartHelper +{ + /** @var Db */ + protected $db; + + /** @var CoreApi */ + protected $api; + + /** @var IcingaApiUser */ + protected $apiUser; + + /** @var IcingaEndpoint */ + protected $deploymentEndpoint; + + /** @var IcingaEndpoint[] */ + protected $loadedEndpoints; + + /** @var IcingaEndpoint[] */ + protected $removeEndpoints; + + /** @var IcingaZone[] */ + protected $loadedZones; + + /** @var IcingaZone[] */ + protected $removeZones; + + /** @var IcingaCommand[] */ + protected $loadedCommands; + + /** @var IcingaCommand[] */ + protected $removeCommands; + + protected $config = [ + 'endpoint' => null, + 'host' => null, + 'port' => null, + 'username' => null, + 'password' => null, + ]; + + /** + * KickstartHelper constructor. + * @param Db $db + */ + public function __construct(Db $db) + { + $this->db = $db; + } + + /** + * Trigger a complete kickstart run + */ + public function run() + { + $this->fetchEndpoints() + ->reconnectToDeploymentEndpoint() + ->fetchZones() + ->fetchCommands() + ->storeZones() + ->storeEndpoints() + ->storeCommands() + ->removeEndpoints() + ->removeZones() + ->removeCommands(); + + $this->apiUser()->store(); + } + + /** + * @return bool + */ + public function isConfigured() + { + $config = $this->fetchConfigFileSection(); + return array_key_exists('endpoint', $config) + && array_key_exists('username', $config); + } + + /** + * @return KickstartHelper + * @throws ProgrammingError + */ + public function loadConfigFromFile() + { + return $this->setConfig($this->fetchConfigFileSection()); + } + + /** + * @return array + */ + protected function fetchConfigFileSection() + { + return Config::module('director', 'kickstart') + ->getSection('config') + ->toArray(); + } + + /** + * @param array $config + * @return $this + * @throws ProgrammingError + */ + public function setConfig($config) + { + foreach ($config as $key => $value) { + if ($value === '') { + continue; + } + + if (! array_key_exists($key, $this->config)) { + throw new ProgrammingError( + '"%s" is not a valid config setting for the kickstart helper', + $key + ); + } + + $this->config[$key] = $value; + } + + return $this; + } + + /** + * @return bool + */ + public function isRequired() + { + $stats = $this->db->getObjectSummary(); + return (int) $stats['apiuser']->cnt_total === 0; + } + + /** + * @param $key + * @param mixed $default + * @return mixed + */ + protected function getValue($key, $default = null) + { + if ($this->config[$key] === null) { + return $default; + } + + return $this->config[$key]; + } + + /** + * @return IcingaApiUser + * @throws \Icinga\Exception\NotFoundError + */ + protected function apiUser() + { + if ($this->apiUser === null) { + $name = $this->getValue('username'); + + $user = IcingaApiUser::create(array( + 'object_name' => $this->getValue('username'), + 'object_type' => 'external_object', + 'password' => $this->getValue('password') + ), $this->db); + + if (IcingaApiUser::exists($name, $this->db)) { + $this->apiUser = IcingaApiUser::load($name, $this->db)->replaceWith($user); + } else { + $this->apiUser = $user; + } + + $this->apiUser->store(); + } + + return $this->apiUser; + } + + /** + * @param IcingaObject[] $objects + * @return IcingaObject[] + * @throws NestingError + */ + protected function sortByParent(array $objects) + { + $sorted = array(); + + $cnt = 0; + while (! empty($objects)) { + $cnt++; + if ($cnt > 20) { + $this->throwObjectLoop($objects); + } + + $unset = array(); + foreach ($objects as $key => $object) { + $parentName = $object->get('parent'); + if ($parentName === null || array_key_exists($parentName, $sorted)) { + $sorted[$object->getObjectName()] = $object; + $unset[] = $key; + } + } + + foreach ($unset as $key) { + unset($objects[$key]); + } + } + + return $sorted; + } + + /** + * @param IcingaObject[] $objects + * @throws NestingError + */ + protected function throwObjectLoop(array $objects) + { + $names = array(); + if (empty($objects)) { + $class = 'Nothing'; + } else { + $class = explode('/\\/', get_class(current($objects)))[0]; + } + + foreach ($objects as $object) { + $names[] = $object->getObjectName(); + } + + throw new NestingError( + 'Loop detected while resolving %s: %s', + $class, + implode(', ', $names) + ); + } + + /** + * @return $this + * @throws NestingError + * @throws \Icinga\Exception\NotFoundError + */ + protected function fetchZones() + { + $db = $this->db; + $this->loadedZones = $this->sortByParent( + $this->api()->setDb($db)->getZoneObjects() + ); + + return $this; + } + + /** + * @return $this + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + protected function storeZones() + { + $db = $this->db; + $existing = IcingaObject::loadAllExternalObjectsByType('zone', $db); + + foreach ($this->loadedZones as $name => $object) { + if (array_key_exists($name, $existing)) { + $object = $existing[$name]->replaceWith($object); + unset($existing[$name]); + } + + $object->store(); + } + + $this->removeZones = $existing; + + return $this; + } + + /** + * @return $this + */ + protected function removeZones() + { + return $this->removeObjects($this->removeEndpoints, 'External Zone'); + } + + /** + * @return $this + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + protected function fetchEndpoints() + { + $db = $this->db; + $this->loadedEndpoints = $this->api()->setDb($db)->getEndpointObjects(); + + $master = $this->getValue('endpoint'); + if (array_key_exists($master, $this->loadedEndpoints)) { + $apiuser = $this->apiUser(); + $apiuser->store(); + $object = $this->loadedEndpoints[$master]; + $object->apiuser = $apiuser->object_name; + $this->deploymentEndpoint = $object; + } + + return $this; + } + + /** + * @return $this + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function storeEndpoints() + { + $db = $this->db; + $existing = IcingaObject::loadAllExternalObjectsByType('endpoint', $db); + + foreach ($this->loadedEndpoints as $name => $object) { + if (array_key_exists($name, $existing)) { + $object = $existing[$name]->replaceWith($object); + unset($existing[$name]); + } + + $object->store(); + } + + $this->removeEndpoints = $existing; + + $db->settings()->master_zone = $this->deploymentEndpoint->zone; + + return $this; + } + + /** + * @return $this + */ + protected function removeEndpoints() + { + return $this->removeObjects($this->removeEndpoints, 'External Endpoint'); + } + + /** + * @return $this + * @throws ConfigurationError + */ + protected function reconnectToDeploymentEndpoint() + { + $master = $this->getValue('endpoint'); + + if (! $this->deploymentEndpoint) { + throw new ConfigurationError( + 'I found no Endpoint object called "%s" on %s:%d', + $master, + $this->getHost(), + $this->getPort() + ); + } + + $ep = $this->deploymentEndpoint; + + $epHost = $ep->get('host'); + if (! $epHost) { + $epHost = $ep->getObjectName(); + } + + try { + $this->switchToDeploymentApi()->getStatus(); + } catch (Exception $e) { + throw new ConfigurationError( + 'I was unable to re-establish a connection to the Endpoint "%s" (%s:%d).' + . ' When reconnecting to the configured Endpoint (%s:%d) I get an error: %s' + . ' Please re-check your Icinga 2 endpoint configuration', + $master, + $this->getHost(), + $this->getPort(), + $epHost, + $ep->get('port'), + $e->getMessage() + ); + } + + return $this; + } + + /** + * @return $this + * @throws \Icinga\Exception\NotFoundError + */ + protected function fetchCommands() + { + $api = $this->api()->setDb($this->db); + $this->loadedCommands = array_merge( + $api->getSpecificCommandObjects('Check'), + $api->getSpecificCommandObjects('Notification'), + $api->getSpecificCommandObjects('Event') + ); + + return $this; + } + + /** + * @return $this + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function storeCommands() + { + $db = $this->db; + $existing = IcingaObject::loadAllExternalObjectsByType('command', $db); + + foreach ($this->loadedCommands as $name => $object) { + if (array_key_exists($name, $existing)) { + $object = $existing[$name]->replaceWith($object); + unset($existing[$name]); + } + + $object->store(); + } + + $this->removeCommands = $existing; + + return $this; + } + + /** + * @return $this + */ + protected function removeCommands() + { + return $this->removeObjects($this->removeCommands, 'External Command'); + } + + protected function removeObjects(array $objects, $typeName) + { + foreach ($objects as $object) { + try { + $object->delete(); + } catch (Exception $e) { + throw new RuntimeException(sprintf( + "Failed to remove %s '%s', it's eventually still in use", + $typeName, + $object->getObjectName() + ), 0, $e); + } + } + + return $this; + } + + /** + * @param Db $db + * @return $this + */ + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + /** + * @return string + */ + protected function getHost() + { + return $this->getValue('host', $this->getValue('endpoint')); + } + + /** + * @return int + */ + protected function getPort() + { + return (int) $this->getValue('port', 5665); + } + + /** + * @return CoreApi + * @throws \Icinga\Exception\NotFoundError + */ + protected function getDeploymentApi() + { + unset($this->api); + $ep = $this->deploymentEndpoint; + + $epHost = $ep->get('host'); + if (!$epHost) { + $epHost = $ep->object_name; + } + + $client = new RestApiClient( + $epHost, + $ep->get('port') + ); + + $apiuser = $this->apiUser(); + $client->setCredentials($apiuser->object_name, $apiuser->password); + + $api = new CoreApi($client); + $api->setDb($this->db); + + return $api; + } + + /** + * @return CoreApi + * @throws \Icinga\Exception\NotFoundError + */ + protected function getConfiguredApi() + { + unset($this->api); + $client = new RestApiClient( + $this->getHost(), + $this->getPort() + ); + + $apiuser = $this->apiUser(); + $client->setCredentials($apiuser->object_name, $apiuser->password); + + $api = new CoreApi($client); + $api->setDb($this->db); + + return $api; + } + + /** + * @return CoreApi + * @throws \Icinga\Exception\NotFoundError + */ + protected function switchToDeploymentApi() + { + return $this->api = $this->getDeploymentApi(); + } + + /** + * @return CoreApi + * @throws \Icinga\Exception\NotFoundError + */ + protected function api() + { + if ($this->api === null) { + $this->api = $this->getConfiguredApi(); + } + + return $this->api; + } +} diff --git a/library/Director/Monitoring.php b/library/Director/Monitoring.php new file mode 100644 index 0000000..f5d4108 --- /dev/null +++ b/library/Director/Monitoring.php @@ -0,0 +1,149 @@ +<?php + +namespace Icinga\Module\Director; + +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Monitoring\Backend\MonitoringBackend; + +class Monitoring +{ + protected $backend; + + public function __construct() + { + $app = Icinga::app(); + $modules = $app->getModuleManager(); + if (!$modules->hasLoaded('monitoring') && $app->isCli()) { + $app->getModuleManager()->loadEnabledModules(); + } + + if ($modules->hasLoaded('monitoring')) { + $this->backend = MonitoringBackend::instance(); + } + } + + public function isAvailable() + { + return $this->backend !== null; + } + + public function hasHost($hostname) + { + return $this->backend->select()->from('hostStatus', [ + 'hostname' => 'host_name', + ])->where('host_name', $hostname)->fetchOne() === $hostname; + } + + public function hasService($hostname, $service) + { + return (array) $this->prepareServiceKeyColumnQuery($hostname, $service)->fetchRow() === [ + 'hostname' => $hostname, + 'service' => $service, + ]; + } + + public function authCanEditHost(Auth $auth, $hostname) + { + if ($auth->hasPermission('director/monitoring/hosts')) { + $restriction = null; + foreach ($auth->getRestrictions('director/monitoring/rw-object-filter') as $restriction) { + if ($this->hasHostWithExtraFilter($hostname, Filter::fromQueryString($restriction))) { + return true; + } + } + if ($restriction === null) { + return $this->hasHost($hostname); + } + } + + return false; + } + + public function authCanEditService(Auth $auth, $hostname, $service) + { + if ($hostname === null || $service === null) { + // TODO: UUID support! + return false; + } + if ($auth->hasPermission('director/monitoring/services')) { + $restriction = null; + foreach ($auth->getRestrictions('director/monitoring/rw-object-filter') as $restriction) { + if ($this->hasServiceWithExtraFilter($hostname, $service, Filter::fromQueryString($restriction))) { + return true; + } + } + if ($restriction === null) { + return $this->hasService($hostname, $service); + } + } + + return false; + } + + public function hasHostWithExtraFilter($hostname, Filter $filter) + { + return $this->backend->select()->from('hostStatus', [ + 'hostname' => 'host_name', + ])->where('host_name', $hostname)->applyFilter($filter)->fetchOne() === $hostname; + } + + public function hasServiceWithExtraFilter($hostname, $service, Filter $filter) + { + return (array) $this + ->prepareServiceKeyColumnQuery($hostname, $service) + ->applyFilter($filter) + ->fetchRow() === [ + 'hostname' => $hostname, + 'service' => $service, + ]; + } + + public function getHostState($hostname) + { + $hostStates = [ + '0' => 'up', + '1' => 'down', + '2' => 'unreachable', + '99' => 'pending', + ]; + + $query = $this->backend->select()->from('hostStatus', [ + 'hostname' => 'host_name', + 'state' => 'host_state', + 'problem' => 'host_problem', + 'acknowledged' => 'host_acknowledged', + 'in_downtime' => 'host_in_downtime', + 'output' => 'host_output', + ])->where('host_name', $hostname); + + $res = $query->fetchRow(); + if ($res === false) { + $res = (object) [ + 'hostname' => $hostname, + 'state' => '99', + 'problem' => '0', + 'acknowledged' => '0', + 'in_downtime' => '0', + 'output' => null, + ]; + } + + $res->state = $hostStates[$res->state]; + + return $res; + } + + protected function prepareServiceKeyColumnQuery($hostname, $service) + { + return $this->backend + ->select() + ->from('serviceStatus', [ + 'hostname' => 'host_name', + 'service' => 'service_description', + ]) + ->where('host_name', $hostname) + ->where('service_description', $service); + } +} diff --git a/library/Director/Objects/DirectorActivityLog.php b/library/Director/Objects/DirectorActivityLog.php new file mode 100644 index 0000000..cb041b6 --- /dev/null +++ b/library/Director/Objects/DirectorActivityLog.php @@ -0,0 +1,232 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Authentication\Auth; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; + +class DirectorActivityLog extends DbObject +{ + const ACTION_CREATE = 'create'; + const ACTION_DELETE = 'delete'; + const ACTION_MODIFY = 'modify'; + + /** @deprecated */ + const AUDIT_REMOVE = 'remove'; + + protected $table = 'director_activity_log'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = [ + 'id' => null, + 'object_name' => null, + 'action_name' => null, + 'object_type' => null, + 'old_properties' => null, + 'new_properties' => null, + 'author' => null, + 'change_time' => null, + 'checksum' => null, + 'parent_checksum' => null, + ]; + + protected $binaryProperties = [ + 'checksum', + 'parent_checksum' + ]; + + /** @var ?string */ + protected static $overriddenUsername = null; + + /** + * @param $name + * + * @codingStandardsIgnoreStart + * + * @return self + */ + protected function setObject_Name($name) + { + // @codingStandardsIgnoreEnd + + if ($name === null) { + $name = ''; + } + + return $this->reallySet('object_name', $name); + } + + public static function username() + { + if (self::$overriddenUsername) { + return self::$overriddenUsername; + } + + if (Icinga::app()->isCli()) { + return 'cli'; + } + + $auth = Auth::getInstance(); + if ($auth->isAuthenticated()) { + return $auth->getUser()->getUsername(); + } elseif (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER)) { + return '<' . $_SERVER['HTTP_X_FORWARDED_FOR'] . '>'; + } elseif (array_key_exists('REMOTE_ADDR', $_SERVER)) { + return '<' . $_SERVER['REMOTE_ADDR'] . '>'; + } else { + return '<unknown>'; + } + } + + protected static function ip() + { + if (Icinga::app()->isCli()) { + return 'cli'; + } + + if (array_key_exists('REMOTE_ADDR', $_SERVER)) { + return $_SERVER['REMOTE_ADDR']; + } else { + return '0.0.0.0'; + } + } + + /** + * @param Db $connection + * @return DirectorActivityLog + * @throws \Icinga\Exception\NotFoundError + */ + public static function loadLatest(Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from('director_activity_log', ['id' => 'MAX(id)']); + + return static::load($db->fetchOne($query), $connection); + } + + public static function logCreation(IcingaObject $object, Db $db) + { + // TODO: extend this to support non-IcingaObjects and multikey objects + $name = $object->getObjectName(); + $type = $object->getTableName(); + $newProps = $object->toJson(null, true); + + $data = [ + 'object_name' => $name, + 'action_name' => self::ACTION_CREATE, + 'author' => static::username(), + 'object_type' => $type, + 'new_properties' => $newProps, + 'change_time' => date('Y-m-d H:i:s'), + 'parent_checksum' => $db->getLastActivityChecksum() + ]; + + $data['checksum'] = sha1(json_encode($data), true); + $data['parent_checksum'] = hex2bin($data['parent_checksum']); + + static::audit($db, [ + 'action' => self::ACTION_CREATE, + 'object_type' => $type, + 'object_name' => $name, + 'new_props' => $newProps, + ]); + + return static::create($data)->store($db); + } + + public static function logModification(IcingaObject $object, Db $db) + { + $name = $object->getObjectName(); + $type = $object->getTableName(); + $oldProps = json_encode($object->getPlainUnmodifiedObject()); + $newProps = $object->toJson(null, true); + + $data = [ + 'object_name' => $name, + 'action_name' => self::ACTION_MODIFY, + 'author' => static::username(), + 'object_type' => $type, + 'old_properties' => $oldProps, + 'new_properties' => $newProps, + 'change_time' => date('Y-m-d H:i:s'), + 'parent_checksum' => $db->getLastActivityChecksum() + ]; + + $data['checksum'] = sha1(json_encode($data), true); + $data['parent_checksum'] = hex2bin($data['parent_checksum']); + + static::audit($db, [ + 'action' => self::ACTION_MODIFY, + 'object_type' => $type, + 'object_name' => $name, + 'old_props' => $oldProps, + 'new_props' => $newProps, + ]); + + return static::create($data)->store($db); + } + + public static function logRemoval(IcingaObject $object, Db $db) + { + $name = $object->getObjectName(); + $type = $object->getTableName(); + $oldProps = json_encode($object->getPlainUnmodifiedObject()); + + $data = [ + 'object_name' => $name, + 'action_name' => self::ACTION_DELETE, + 'author' => static::username(), + 'object_type' => $type, + 'old_properties' => $oldProps, + 'change_time' => date('Y-m-d H:i:s'), + 'parent_checksum' => $db->getLastActivityChecksum() + ]; + + $data['checksum'] = sha1(json_encode($data), true); + $data['parent_checksum'] = hex2bin($data['parent_checksum']); + + static::audit($db, [ + 'action' => self::AUDIT_REMOVE, + 'object_type' => $type, + 'object_name' => $name, + 'old_props' => $oldProps + ]); + + return static::create($data)->store($db); + } + + public static function audit(Db $db, $properties) + { + if ($db->settings()->get('enable_audit_log') !== 'y') { + return; + } + + $log = []; + $properties = array_merge([ + 'username' => static::username(), + 'address' => static::ip(), + ], $properties); + + foreach ($properties as $key => $val) { + $log[] = "$key=" . json_encode($val); + } + + Logger::info('(director) ' . implode(' ', $log)); + } + + public static function overrideUsername($username) + { + self::$overriddenUsername = $username; + } + + public static function restoreUsername() + { + self::$overriddenUsername = null; + } +} diff --git a/library/Director/Objects/DirectorDatafield.php b/library/Director/Objects/DirectorDatafield.php new file mode 100644 index 0000000..84db068 --- /dev/null +++ b/library/Director/Objects/DirectorDatafield.php @@ -0,0 +1,344 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\CompareBasketObject; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Forms\IcingaServiceForm; +use Icinga\Module\Director\Hook\DataTypeHook; +use Icinga\Module\Director\Resolver\OverriddenVarsResolver; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use InvalidArgumentException; +use Zend_Form_Element as ZfElement; + +class DirectorDatafield extends DbObjectWithSettings +{ + protected $table = 'director_datafield'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = [ + 'id' => null, + 'category_id' => null, + 'varname' => null, + 'caption' => null, + 'description' => null, + 'datatype' => null, + 'format' => null, + ]; + + protected $relations = [ + 'category' => 'DirectorDatafieldCategory' + ]; + + protected $settingsTable = 'director_datafield_setting'; + + protected $settingsRemoteId = 'datafield_id'; + + /** @var DirectorDatafieldCategory|null */ + private $category; + + private $object; + + public static function fromDbRow($row, Db $connection) + { + $obj = static::create((array) $row, $connection); + $obj->loadedFromDb = true; + // TODO: $obj->setUnmodified(); + $obj->hasBeenModified = false; + $obj->modifiedProperties = array(); + $settings = $obj->getSettings(); + // TODO: eventually prefetch + $obj->onLoadFromDb(); + + // Restoring values eventually destroyed by onLoadFromDb + foreach ($settings as $key => $value) { + $obj->settings[$key] = $value; + } + + return $obj; + } + + public function hasCategory() + { + return $this->category !== null || $this->get('category_id') !== null; + } + + /** + * @return DirectorDatafieldCategory|null + * @throws \Icinga\Exception\NotFoundError + */ + public function getCategory() + { + if ($this->category) { + return $this->category; + } elseif ($id = $this->get('category_id')) { + return DirectorDatafieldCategory::loadWithAutoIncId($id, $this->getConnection()); + } else { + return null; + } + } + + public function getCategoryName() + { + $category = $this->getCategory(); + if ($category === null) { + return null; + } else { + return $category->get('category_name'); + } + } + + public function setCategory($category) + { + if ($category === null) { + $this->category = null; + $this->set('category_id', null); + } elseif ($category instanceof DirectorDatafieldCategory) { + if ($category->hasBeenLoadedFromDb()) { + $this->set('category_id', $category->get('id')); + } + $this->category = $category; + } else { + if (DirectorDatafieldCategory::exists($category, $this->getConnection())) { + $this->setCategory(DirectorDatafieldCategory::load($category, $this->getConnection())); + } else { + $this->setCategory(DirectorDatafieldCategory::create([ + 'category_name' => $category + ], $this->getConnection())); + } + } + + return $this; + } + + /** + * @return object + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + $plain = (object) $this->getProperties(); + $plain->originalId = $plain->id; + unset($plain->id); + $plain->settings = (object) $this->getSettings(); + + if (property_exists($plain->settings, 'datalist_id')) { + $plain->settings->datalist = DirectorDatalist::loadWithAutoIncId( + $plain->settings->datalist_id, + $this->getConnection() + )->get('list_name'); + unset($plain->settings->datalist_id); + } + if (property_exists($plain, 'category_id')) { + $plain->category = $this->getCategoryName(); + unset($plain->category_id); + } + + return $plain; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return DirectorDatafield + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + if (isset($properties['originalId'])) { + $id = $properties['originalId']; + unset($properties['originalId']); + } else { + $id = null; + } + + if (isset($properties['settings']->datalist)) { + // Just try to load the list, import should fail if missing + $list = DirectorDatalist::load( + $properties['settings']->datalist, + $db + ); + } else { + $list = null; + } + + $compare = Json::decode(Json::encode($properties)); + if ($id && static::exists($id, $db)) { + $existing = static::loadWithAutoIncId($id, $db); + $existingProperties = (array) $existing->export(); + unset($existingProperties['originalId']); + if (CompareBasketObject::equals((object) $compare, (object) $existingProperties)) { + return $existing; + } + } + + if ($list) { + unset($properties['settings']->datalist); + $properties['settings']->datalist_id = $list->get('id'); + } + + $dba = $db->getDbAdapter(); + $query = $dba->select() + ->from('director_datafield') + ->where('varname = ?', $plain->varname); + $candidates = DirectorDatafield::loadAll($db, $query); + + foreach ($candidates as $candidate) { + $export = $candidate->export(); + unset($export->originalId); + CompareBasketObject::normalize($export); + if (CompareBasketObject::equals($export, $compare)) { + return $candidate; + } + } + + return static::create($properties, $db); + } + + protected function beforeStore() + { + if ($this->category) { + if (!$this->category->hasBeenLoadedFromDb()) { + throw new \RuntimeException('Trying to store a datafield with an unstored Category'); + } + $this->set('category_id', $this->category->get('id')); + } + } + + protected function setObject(IcingaObject $object) + { + $this->object = $object; + } + + protected function getObject() + { + return $this->object; + } + + public function getFormElement(DirectorObjectForm $form, $name = null) + { + $className = $this->get('datatype'); + + if ($name === null) { + $name = 'var_' . $this->get('varname'); + } + + if (! class_exists($className)) { + $form->addElement('text', $name, array('disabled' => 'disabled')); + $el = $form->getElement($name); + $msg = $form->translate('Form element could not be created, %s is missing'); + $el->addError(sprintf($msg, $className)); + return $el; + } + + /** @var DataTypeHook $dataType */ + $dataType = new $className; + $dataType->setSettings($this->getSettings()); + $el = $dataType->getFormElement($name, $form); + + if ($this->getSetting('icinga_type') !== 'command' + && $this->getSetting('is_required') === 'y' + ) { + $el->setRequired(true); + } + if ($caption = $this->get('caption')) { + $el->setLabel($caption); + } + + if ($description = $this->get('description')) { + $el->setDescription($description); + } + + $this->applyObjectData($el, $form); + + return $el; + } + + protected function applyObjectData(ZfElement $el, DirectorObjectForm $form) + { + $object = $form->getObject(); + if (! ($object instanceof IcingaObject)) { + return; + } + if ($object->isTemplate()) { + $el->setRequired(false); + } + + $varName = $this->get('varname'); + $inherited = $origin = null; + + if ($form instanceof IcingaServiceForm && $form->providesOverrides()) { + $resolver = new OverriddenVarsResolver($form->getDb()); + $vars = $resolver->fetchForServiceName($form->getHost(), $object->getObjectName()); + foreach ($vars as $host => $values) { + if (\property_exists($values, $varName)) { + $inherited = $values->$varName; + $origin = $host; + } + } + } + + if ($inherited === null) { + $inherited = $object->getInheritedVar($varName); + if (null !== $inherited) { + $origin = $object->getOriginForVar($varName); + } + } + + if ($inherited === null) { + $cmd = $this->eventuallyGetResolvedCommandVar($object, $varName); + if ($cmd !== null) { + list($inherited, $origin) = $cmd; + } + } + + if ($inherited !== null) { + $form->setInheritedValue($el, $inherited, $origin); + } + } + + protected function eventuallyGetResolvedCommandVar(IcingaObject $object, $varName) + { + if (! $object->hasRelation('check_command')) { + return null; + } + + // TODO: Move all of this elsewhere and test it + try { + /** @var IcingaCommand $command */ + $command = $object->getResolvedRelated('check_command'); + if ($command === null) { + return null; + } + $inherited = $command->vars()->get($varName); + $inheritedFrom = null; + + if ($inherited !== null) { + $inherited = $inherited->getValue(); + } + + if ($inherited === null) { + $inherited = $command->getResolvedVar($varName); + if ($inherited === null) { + $inheritedFrom = $command->getOriginForVar($varName); + } + } else { + $inheritedFrom = $command->getObjectName(); + } + + $inherited = $command->getResolvedVar($varName); + + return [$inherited, $inheritedFrom]; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/library/Director/Objects/DirectorDatafieldCategory.php b/library/Director/Objects/DirectorDatafieldCategory.php new file mode 100644 index 0000000..6cb4fb4 --- /dev/null +++ b/library/Director/Objects/DirectorDatafieldCategory.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +class DirectorDatafieldCategory extends DbObject +{ + protected $table = 'director_datafield_category'; + + protected $keyName = 'category_name'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = [ + 'id' => null, + 'category_name' => null, + 'description' => null, + ]; + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return object + */ + public function export() + { + $plain = (object) $this->getProperties(); + $plain->originalId = $plain->id; + unset($plain->id); + return $plain; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + unset($properties['originalId']); + $key = $properties['category_name']; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Cannot import, DatafieldCategory "%s" already exists', + $key + ); + } else { + $object = static::create([], $db); + } + + $object->setProperties($properties); + + return $object; + } +} diff --git a/library/Director/Objects/DirectorDatalist.php b/library/Director/Objects/DirectorDatalist.php new file mode 100644 index 0000000..ae5c983 --- /dev/null +++ b/library/Director/Objects/DirectorDatalist.php @@ -0,0 +1,225 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +class DirectorDatalist extends DbObject implements ExportInterface +{ + protected $table = 'director_datalist'; + + protected $keyName = 'list_name'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = array( + 'id' => null, + 'list_name' => null, + 'owner' => null + ); + + /** @var DirectorDatalistEntry[] */ + protected $storedEntries; + + public function getUniqueIdentifier() + { + return $this->get('list_name'); + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws \Icinga\Exception\NotFoundError + * @throws DuplicateKeyException + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + if (isset($properties['originalId'])) { + unset($properties['originalId']); + } else { + $id = null; + } + $name = $properties['list_name']; + + if ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::exists($name, $db)) { + throw new DuplicateKeyException( + 'Data List %s already exists', + $name + ); + } else { + $object = static::create([], $db); + } + $object->setProperties($properties); + + return $object; + } + + public function setEntries($entries) + { + $existing = $this->getStoredEntries(); + + $new = []; + $seen = []; + $modified = false; + + foreach ($entries as $entry) { + $name = $entry->entry_name; + $entry = DirectorDatalistEntry::create((array) $entry); + $seen[$name] = true; + if (isset($existing[$name])) { + $existing[$name]->replaceWith($entry); + if (! $modified && $existing[$name]->hasBeenModified()) { + $modified = true; + } + } else { + $modified = true; + $new[] = $entry; + } + } + + foreach (array_keys($existing) as $key) { + if (! isset($seen[$key])) { + $existing[$key]->markForRemoval(); + $modified = true; + } + } + + foreach ($new as $entry) { + $existing[$entry->get('entry_name')] = $entry; + } + + if ($modified) { + $this->hasBeenModified = true; + } + + $this->storedEntries = $existing; + ksort($this->storedEntries); + + return $this; + } + + protected function beforeDelete() + { + if ($this->hasBeenUsed()) { + throw new Exception( + sprintf( + "Cannot delete '%s', as the datalist '%s' is currently being used.", + $this->get('list_name'), + $this->get('list_name') + ) + ); + } + } + + protected function hasBeenUsed() + { + $datalistType = 'Icinga\\Module\\Director\\DataType\\DataTypeDatalist'; + $db = $this->getDb(); + + $dataFieldsCheck = $db->select() + ->from(['df' =>'director_datafield'], ['varname']) + ->join( + ['dfs' => 'director_datafield_setting'], + 'dfs.datafield_id = df.id AND dfs.setting_name = \'datalist_id\'', + [] + ) + ->join( + ['l' => 'director_datalist'], + 'l.id = dfs.setting_value', + [] + ) + ->where('datatype = ?', $datalistType) + ->where('setting_value = ?', $this->get('id')); + + if ($db->fetchOne($dataFieldsCheck)) { + return true; + } + + $syncCheck = $db->select() + ->from(['sp' =>'sync_property'], ['source_expression']) + ->where('sp.destination_field = ?', 'list_id') + ->where('sp.source_expression = ?', $this->get('id')); + + if ($db->fetchOne($syncCheck)) { + return true; + } + + return false; + } + + /** + * @throws DuplicateKeyException + */ + public function onStore() + { + if ($this->storedEntries) { + $db = $this->getConnection(); + $removedKeys = []; + $myId = $this->get('id'); + + foreach ($this->storedEntries as $key => $entry) { + if ($entry->shouldBeRemoved()) { + $entry->delete(); + $removedKeys[] = $key; + } else { + if (! $entry->hasBeenLoadedFromDb()) { + $entry->set('list_id', $myId); + } + $entry->set('list_id', $myId); + $entry->store($db); + } + } + + foreach ($removedKeys as $key) { + unset($this->storedEntries[$key]); + } + } + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return object + */ + public function export() + { + $plain = (object) $this->getProperties(); + $plain->originalId = $plain->id; + unset($plain->id); + + $plain->entries = []; + foreach ($this->getStoredEntries() as $key => $entry) { + if ($entry->shouldBeRemoved()) { + continue; + } + $plainEntry = (object) $entry->getProperties(); + unset($plainEntry->list_id); + + $plain->entries[] = $plainEntry; + } + + return $plain; + } + + protected function getStoredEntries() + { + if ($this->storedEntries === null) { + if ($id = $this->get('id')) { + $this->storedEntries = DirectorDatalistEntry::loadAllForList($this); + ksort($this->storedEntries); + } else { + $this->storedEntries = []; + } + } + + return $this->storedEntries; + } +} diff --git a/library/Director/Objects/DirectorDatalistEntry.php b/library/Director/Objects/DirectorDatalistEntry.php new file mode 100644 index 0000000..086686a --- /dev/null +++ b/library/Director/Objects/DirectorDatalistEntry.php @@ -0,0 +1,112 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; +use RuntimeException; + +class DirectorDatalistEntry extends DbObject +{ + protected $keyName = ['list_id', 'entry_name']; + + protected $table = 'director_datalist_entry'; + + private $shouldBeRemoved = false; + + protected $defaultProperties = [ + 'list_id' => null, + 'entry_name' => null, + 'entry_value' => null, + 'format' => null, + 'allowed_roles' => null, + ]; + + /** + * @param DirectorDatalist $list + * @return static[] + */ + public static function loadAllForList(DirectorDatalist $list) + { + $query = $list->getDb() + ->select() + ->from('director_datalist_entry') + ->where('list_id = ?', $list->get('id')) + ->order('entry_name ASC'); + + return static::loadAll($list->getConnection(), $query, 'entry_name'); + } + + /** + * @param $roles + * @codingStandardsIgnoreStart + */ + public function setAllowed_roles($roles) + { + // @codingStandardsIgnoreEnd + $key = 'allowed_roles'; + if (is_array($roles)) { + $this->reallySet($key, json_encode($roles)); + } elseif (null === $roles) { + $this->reallySet($key, null); + } else { + throw new RuntimeException( + 'Expected array or null for allowed_roles, got %s', + var_export($roles, 1) + ); + } + } + + /** + * @return array|null + * @codingStandardsIgnoreStart + */ + public function getAllowed_roles() + { + // @codingStandardsIgnoreEnd + $roles = $this->getProperty('allowed_roles'); + if (is_string($roles)) { + return json_decode($roles); + } else { + return $roles; + } + } + + public function replaceWith(DirectorDatalistEntry $object) + { + $this->set('entry_value', $object->get('entry_value')); + if ($object->get('format')) { + $this->set('format', $object->get('format')); + } + + return $this; + } + + public function merge(DirectorDatalistEntry $object) + { + return $this->replaceWith($object); + } + + public function markForRemoval($remove = true) + { + $this->shouldBeRemoved = $remove; + + return $this; + } + + public function shouldBeRemoved() + { + return $this->shouldBeRemoved; + } + + public function onInsert() + { + } + + public function onUpdate() + { + } + + public function onDelete() + { + } +} diff --git a/library/Director/Objects/DirectorDeploymentLog.php b/library/Director/Objects/DirectorDeploymentLog.php new file mode 100644 index 0000000..0794a3c --- /dev/null +++ b/library/Director/Objects/DirectorDeploymentLog.php @@ -0,0 +1,199 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Util; + +class DirectorDeploymentLog extends DbObject +{ + protected $table = 'director_deployment_log'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $config; + + protected $defaultProperties = [ + 'id' => null, + 'config_checksum' => null, + 'last_activity_checksum' => null, + 'peer_identity' => null, + 'start_time' => null, + 'end_time' => null, + 'abort_time' => null, + 'duration_connection' => null, + 'duration_dump' => null, + 'stage_name' => null, + 'stage_collected' => null, + 'connection_succeeded' => null, + 'dump_succeeded' => null, + 'startup_succeeded' => null, + 'username' => null, + 'startup_log' => null, + ]; + + protected $binaryProperties = [ + 'config_checksum', + 'last_activity_checksum' + ]; + + public function getConfigHexChecksum() + { + return bin2hex($this->config_checksum); + } + + public function getConfig() + { + if ($this->config === null) { + $this->config = IcingaConfig::load($this->config_checksum, $this->connection); + } + + return $this->config; + } + + public function isPending() + { + return $this->dump_succeeded === 'y' && $this->startup_log === null; + } + + public function succeeded() + { + return $this->startup_succeeded === 'y'; + } + + public function configEquals(IcingaConfig $config) + { + return $this->config_checksum === $config->getChecksum(); + } + + public function getDeploymentTimestamp() + { + return strtotime($this->start_time); + } + + public static function hasDeployments(Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + 'director_deployment_log', + array('c' => 'COUNT(*)') + ); + + return (int) $db->fetchOne($query) > 0; + } + + public static function getConfigChecksumForStageName(Db $connection, $stage) + { + if ($stage === null) { + return null; + } + $db = $connection->getDbAdapter(); + $query = $db->select() + ->from( + array('l' => 'director_deployment_log'), + array('c' => $connection->dbHexFunc('l.config_checksum')) + )->where('l.stage_name = ?'); + + return $db->fetchOne($query, $stage); + } + + /** + * @param Db $connection + * @return DirectorDeploymentLog + * @throws NotFoundError + */ + public static function loadLatest(Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + array('l' => 'director_deployment_log'), + array('id' => 'MAX(l.id)') + ); + + return static::load($db->fetchOne($query), $connection); + } + + /** + * @param Db $connection + * @return ?DirectorDeploymentLog + */ + public static function optionalLatest(Db $connection) + { + try { + return static::loadLatest($connection); + } catch (NotFoundError $exception) { + return null; + } + } + + /** + * @param CoreApi $api + * @param Db $connection + * @return DirectorDeploymentLog + */ + public static function getRelatedToActiveStage(CoreApi $api, Db $connection) + { + try { + return static::requireRelatedToActiveStage($api, $connection); + } catch (Exception $e) { + return null; + } + } + + /** + * @param CoreApi $api + * @param Db $connection + * @return DirectorDeploymentLog + * @throws NotFoundError + */ + public static function requireRelatedToActiveStage(CoreApi $api, Db $connection) + { + $stage = $api->getActiveStageName(); + + if (! strlen($stage)) { + throw new NotFoundError('Got no active stage name'); + } + $db = $connection->getDbAdapter(); + $query = $db->select()->from( + ['l' => 'director_deployment_log'], + ['id' => 'MAX(l.id)'] + )->where('l.stage_name = ?', $stage); + + return static::load($db->fetchOne($query), $connection); + } + + /** + * @return static[] + */ + public static function getUncollected(Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select() + ->from('director_deployment_log') + ->where('stage_name IS NOT NULL') + ->where('stage_collected IS NULL') + ->where('startup_succeeded IS NULL') + ->order('stage_name'); + + return static::loadAll($connection, $query, 'stage_name'); + } + + public static function hasUncollected(Db $connection) + { + $db = $connection->getDbAdapter(); + $query = $db->select() + ->from('director_deployment_log', ['cnt' => 'COUNT(*)']) + ->where('stage_name IS NOT NULL') + ->where('stage_collected IS NULL') + ->where('startup_succeeded IS NULL'); + + return $db->fetchOne($query) > 0; + } +} diff --git a/library/Director/Objects/DirectorJob.php b/library/Director/Objects/DirectorJob.php new file mode 100644 index 0000000..361f764 --- /dev/null +++ b/library/Director/Objects/DirectorJob.php @@ -0,0 +1,314 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Daemon\Logger; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Hook\JobHook; +use Exception; +use InvalidArgumentException; + +class DirectorJob extends DbObjectWithSettings implements ExportInterface, InstantiatedViaHook +{ + /** @var JobHook */ + protected $job; + + protected $table = 'director_job'; + + protected $keyName = 'job_name'; + + protected $autoincKeyName = 'id'; + + protected $protectAutoinc = false; + + protected $defaultProperties = [ + 'id' => null, + 'job_name' => null, + 'job_class' => null, + 'disabled' => null, + 'run_interval' => null, + 'last_attempt_succeeded' => null, + 'ts_last_attempt' => null, + 'ts_last_error' => null, + 'last_error_message' => null, + 'timeperiod_id' => null, + ]; + + protected $stateProperties = [ + 'last_attempt_succeeded', + 'last_error_message', + 'ts_last_attempt', + 'ts_last_error', + ]; + + protected $settingsTable = 'director_job_setting'; + + protected $settingsRemoteId = 'job_id'; + + public function getUniqueIdentifier() + { + return $this->get('job_name'); + } + + /** + * @deprecated please use JobHook::getInstance() + * @return JobHook + */ + public function job() + { + return $this->getInstance(); + } + + /** + * @return JobHook + */ + public function getInstance() + { + if ($this->job === null) { + $class = $this->get('job_class'); + $this->job = new $class; + $this->job->setDb($this->connection); + $this->job->setDefinition($this); + } + + return $this->job; + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function run() + { + $job = $this->getInstance(); + $this->set('ts_last_attempt', date('Y-m-d H:i:s')); + + try { + $job->run(); + $this->set('last_attempt_succeeded', 'y'); + $success = true; + } catch (Exception $e) { + Logger::error($e->getMessage()); + $this->set('ts_last_error', date('Y-m-d H:i:s')); + $this->set('last_error_message', $e->getMessage()); + $this->set('last_attempt_succeeded', 'n'); + $success = false; + } + + if ($this->hasBeenModified()) { + $this->store(); + } + + return $success; + } + + /** + * @return bool + */ + public function shouldRun() + { + return (! $this->hasBeenDisabled()) && $this->isPending(); + } + + /** + * @return bool + */ + public function isOverdue() + { + if (! $this->shouldRun()) { + return false; + } + + return ( + strtotime((int) $this->get('ts_last_attempt')) + $this->get('run_interval') * 2 + ) < time(); + } + + public function hasBeenDisabled() + { + return $this->get('disabled') === 'y'; + } + + /** + * @return bool + */ + public function isPending() + { + if ($this->get('ts_last_attempt') === null) { + return $this->isWithinTimeperiod(); + } + + if (strtotime($this->get('ts_last_attempt')) + $this->get('run_interval') < time()) { + return $this->isWithinTimeperiod(); + } + + return false; + } + + /** + * @return bool + */ + public function isWithinTimeperiod() + { + if ($this->hasTimeperiod()) { + return $this->timeperiod()->isActive(); + } else { + return true; + } + } + + public function lastAttemptSucceeded() + { + return $this->get('last_attempt_succeeded') === 'y'; + } + + public function lastAttemptFailed() + { + return $this->get('last_attempt_succeeded') === 'n'; + } + + public function hasTimeperiod() + { + return $this->get('timeperiod_id') !== null; + } + + /** + * @param $timeperiod + * @return $this + * @throws \Icinga\Exception\NotFoundError + */ + public function setTimeperiod($timeperiod) + { + if (is_string($timeperiod)) { + $timeperiod = IcingaTimePeriod::load($timeperiod, $this->connection); + } elseif (! $timeperiod instanceof IcingaTimePeriod) { + throw new InvalidArgumentException('TimePeriod expected'); + } + + $this->set('timeperiod_id', $timeperiod->get('id')); + + return $this; + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + $plain = (object) $this->getProperties(); + $plain->originalId = $plain->id; + unset($plain->id); + unset($plain->timeperiod_id); + if ($this->hasTimeperiod()) { + $plain->timeperiod = $this->timeperiod()->getObjectName(); + } + + foreach ($this->stateProperties as $key) { + unset($plain->$key); + } + $plain->settings = $this->getInstance()->exportSettings(); + + return $plain; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return DirectorJob + * @throws DuplicateKeyException + * @throws NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $dummy = new static; + $idCol = $dummy->autoincKeyName; + $keyCol = $dummy->keyName; + $properties = (array) $plain; + if (isset($properties['originalId'])) { + $id = $properties['originalId']; + unset($properties['originalId']); + } else { + $id = null; + } + $name = $properties[$keyCol]; + + if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) { + $object = static::loadWithAutoIncId($id, $db); + } elseif ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::exists($name, $db)) { + throw new DuplicateKeyException( + 'Director Job "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $settings = (array) $properties['settings']; + + if (array_key_exists('source', $settings) && ! (array_key_exists('source_id', $settings))) { + $val = ImportSource::load($settings['source'], $db)->get('id'); + $settings['source_id'] = $val; + unset($settings['source']); + } + + if (array_key_exists('rule', $settings) && ! (array_key_exists('rule_id', $settings))) { + $val = SyncRule::load($settings['rule'], $db)->get('id'); + $settings['rule_id'] = $val; + unset($settings['rule']); + } + + $properties['settings'] = (object) $settings; + $object->setProperties($properties); + if ($id !== null) { + $object->reallySet($idCol, $id); + } + + return $object; + } + + /** + * @param string $name + * @param int $id + * @param Db $connection + * @api internal + * @return bool + */ + protected static function existsWithNameAndId($name, $id, Db $connection) + { + $db = $connection->getDbAdapter(); + $dummy = new static; + $idCol = $dummy->autoincKeyName; + $keyCol = $dummy->keyName; + + return (string) $id === (string) $db->fetchOne( + $db->select() + ->from($dummy->table, $idCol) + ->where("$idCol = ?", $id) + ->where("$keyCol = ?", $name) + ); + } + + /** + * @api internal Exporter only + * @return IcingaTimePeriod + */ + public function timeperiod() + { + try { + return IcingaTimePeriod::loadWithAutoIncId($this->get('timeperiod_id'), $this->connection); + } catch (NotFoundError $e) { + throw new \RuntimeException(sprintf( + 'The TimePeriod configured for Job "%s" could not have been found', + $this->get('name') + )); + } + } +} diff --git a/library/Director/Objects/DynamicApplyMatches.php b/library/Director/Objects/DynamicApplyMatches.php new file mode 100644 index 0000000..9341d1a --- /dev/null +++ b/library/Director/Objects/DynamicApplyMatches.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class DynamicApplyMatches extends ObjectApplyMatches +{ + protected static $type = ''; + + public static function setType($type) + { + static::$type = $type; + return static::$type; + } +} diff --git a/library/Director/Objects/Extension/Arguments.php b/library/Director/Objects/Extension/Arguments.php new file mode 100644 index 0000000..3acbdd3 --- /dev/null +++ b/library/Director/Objects/Extension/Arguments.php @@ -0,0 +1,61 @@ +<?php + +namespace Icinga\Module\Director\Objects\Extension; + +use Icinga\Module\Director\Objects\IcingaArguments; +use Icinga\Module\Director\Objects\IcingaObject; + +trait Arguments +{ + private $arguments; + + public function arguments() + { + /** @var IcingaObject $this */ + if ($this->arguments === null) { + if ($this->hasBeenLoadedFromDb()) { + $this->arguments = IcingaArguments::loadForStoredObject($this); + } else { + $this->arguments = new IcingaArguments($this); + } + } + + return $this->arguments; + } + + public function gotArguments() + { + return null !== $this->arguments; + } + + public function unsetArguments() + { + unset($this->arguments); + } + + /** + * @return string + */ + protected function renderArguments() + { + return $this->arguments()->toConfigString(); + } + + /** + * @param $value + * @return $this + */ + protected function setArguments($value) + { + $this->arguments()->setArguments($value); + return $this; + } + + /** + * @return array + */ + protected function getArguments() + { + return $this->arguments()->toPlainObject(); + } +} diff --git a/library/Director/Objects/Extension/FlappingSupport.php b/library/Director/Objects/Extension/FlappingSupport.php new file mode 100644 index 0000000..a86f10d --- /dev/null +++ b/library/Director/Objects/Extension/FlappingSupport.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\Objects\Extension; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; + +trait FlappingSupport +{ + /** + * @param $value + * @return string + * @codingStandardsIgnoreStart + */ + protected function renderFlapping_threshold_high($value) + { + return $this->renderFlappingThreshold('flapping_threshold_high', $value); + } + + /** + * @param $value + * @return string + */ + protected function renderFlapping_threshold_low($value) + { + return $this->renderFlappingThreshold('flapping_threshold_low', $value); + } + + protected function renderFlappingThreshold($key, $value) + { + return sprintf( + " try { // This setting is only available in Icinga >= 2.8.0\n" + . " %s" + . " } except { globals.directorWarnOnceForThresholds() }\n", + c::renderKeyValue($key, c::renderFloat($value)) + ); + } + + protected function renderLegacyEnable_flapping($value) + { + return c1::renderKeyValue('flap_detection_enabled', c1::renderBoolean($value)); + } + + protected function renderLegacyFlapping_threshold_high($value) + { + return c1::renderKeyValue('high_flap_threshold', $value); + } + + protected function renderLegacyFlapping_threshold_low($value) + { + // @codingStandardsIgnoreEnd + return c1::renderKeyValue('low_flap_threshold', $value); + } +} diff --git a/library/Director/Objects/Extension/PriorityColumn.php b/library/Director/Objects/Extension/PriorityColumn.php new file mode 100644 index 0000000..638bdc6 --- /dev/null +++ b/library/Director/Objects/Extension/PriorityColumn.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Objects\Extension; + +use Zend_Db_Expr as Expr; + +trait PriorityColumn +{ + public function setNextPriority($prioSetColumn = null, $prioColumn = 'priority') + { + /** @var \Zend_Db_Adapter_Abstract $db */ + $db = $this->getDb(); + $prioValue = '(CASE WHEN MAX(priosub.priority) IS NULL THEN 1' + . ' ELSE MAX(priosub.priority) + 1 END)'; + $query = $db->select() + ->from( + ['priosub' => $this->getTableName()], + "$prioValue" + ); + + if ($prioSetColumn !== null) { + $query->where("priosub.$prioSetColumn = ?", $this->get($prioSetColumn)); + } + + $this->set($prioColumn, new Expr('(' . $query . ')')); + + return $this; + } + + protected function refreshPriortyProperty($prioColumn = 'priority') + { + /** @var \Zend_Db_Adapter_Abstract $db */ + $db = $this->getDb(); + $idCol = $this->getAutoincKeyName(); + $query = $db->select() + ->from($this->getTableName(), $prioColumn) + ->where("$idCol = ?", $this->get($idCol)); + $this->reallySet($prioColumn, $db->fetchOne($query)); + } +} diff --git a/library/Director/Objects/GroupMembershipResolver.php b/library/Director/Objects/GroupMembershipResolver.php new file mode 100644 index 0000000..f5ef418 --- /dev/null +++ b/library/Director/Objects/GroupMembershipResolver.php @@ -0,0 +1,689 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\IcingaObjectFilterHelper; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use InvalidArgumentException; +use LogicException; +use Zend_Db_Select as ZfSelect; + +/** + * Class GroupMembershipResolver + * + * - Fetches all involved assignments + * - Fetch all (or one) object + * - Fetch all (or one) group + */ +abstract class GroupMembershipResolver +{ + /** @var string Object type, 'host', 'service', 'user' or similar */ + protected $type; + + /** @var array */ + protected $existingMappings; + + /** @var array */ + protected $newMappings; + + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var IcingaObject[] */ + protected $objects; + + /** @var IcingaObjectGroup[] */ + protected $groups = array(); + + /** @var array */ + protected $staticGroups = array(); + + /** @var bool */ + protected $deferred = false; + + /** @var bool */ + protected $checked = false; + + /** @var bool */ + protected $useTransactions = false; + + protected $groupMap; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + /** + * @return $this + * @throws \Zend_Db_Adapter_Exception + */ + public function refreshAllMappings() + { + return $this->clearGroups()->clearObjects()->refreshDb(true); + } + + public function checkDb() + { + if ($this->checked) { + return $this; + } + + if ($this->isDeferred()) { + // ensure we are not working with cached data + IcingaTemplateRepository::clear(); + } + + Benchmark::measure('Rechecking all objects'); + $this->recheckAllObjects($this->getAppliedGroups()); + if (empty($this->objects) && empty($this->groups)) { + Benchmark::measure('Nothing to check, got no qualified object'); + return $this; + } + + Benchmark::measure('Recheck done, loading existing mappings'); + $this->fetchStoredMappings(); + Benchmark::measure('Got stored group mappings'); + + $this->checked = true; + return $this; + } + + /** + * @param bool $force + * @return $this + * @throws \Zend_Db_Adapter_Exception + */ + public function refreshDb($force = false) + { + if ($force || ! $this->isDeferred()) { + $this->checkDb(); + + if (empty($this->objects) && empty($this->groups)) { + Benchmark::measure('Nothing to check, got no qualified object'); + + return $this; + } + + Benchmark::measure('Ready, going to store new mappings'); + $this->storeNewMappings(); + $this->removeOutdatedMappings(); + Benchmark::measure('Updated group mappings in db'); + } + + return $this; + } + + /** + * @param bool $defer + * @return $this + */ + public function defer($defer = true) + { + $this->deferred = $defer; + return $this; + } + + /** + * @param $use + * @return $this + */ + public function setUseTransactions($use) + { + $this->useTransactions = $use; + return $this; + } + + public function getType() + { + if ($this->type === null) { + throw new LogicException(sprintf( + '"type" is required when extending %s, got none in %s', + __CLASS__, + get_class($this) + )); + } + + return $this->type; + } + + /** + * @return bool + */ + public function isDeferred() + { + return $this->deferred; + } + + /** + * @param IcingaObject $object + * @return $this + */ + public function addObject(IcingaObject $object) + { + // Hint: cannot use hasBeenLoadedFromDB, as it is false in onStore() + // for new objects + if (null === ($id = $object->get('id'))) { + return $this; + } + // Disabling for now, how should this work? + // $this->assertBeenLoadedFromDb($object); + if ($this->objects === null) { + $this->objects = []; + } + + if ($object->isTemplate()) { + $this->includeChildObjects($object); + } else { + $this->objects[$id] = $object; + } + + return $this; + } + + /** + * @param IcingaObject[] $objects + * @return $this + */ + public function addObjects(array $objects) + { + foreach ($objects as $object) { + $this->addObject($object); + } + + return $this; + } + + protected function includeChildObjects(IcingaObject $object) + { + $query = $this->db->select() + ->from(['o' => $object->getTableName()]) + ->where('o.object_type = ?', 'object'); + + IcingaObjectFilterHelper::filterByTemplate( + $query, + $object, + 'o', + Db\IcingaObjectFilterHelper::INHERIT_DIRECT_OR_INDIRECT + ); + + foreach ($object::loadAll($this->connection, $query) as $child) { + $this->objects[$child->getProperty('id')] = $child; + } + + return $this; + } + + /** + * @param IcingaObject $object + * @return $this + */ + public function setObject(IcingaObject $object) + { + $this->clearObjects(); + return $this->addObject($object); + } + + /** + * @param IcingaObject[] $objects + * @return $this + */ + public function setObjects(array $objects) + { + $this->clearObjects(); + return $this->addObjects($objects); + } + + /** + * @return $this + */ + public function clearObjects() + { + $this->objects = array(); + return $this; + } + + /** + * @param IcingaObjectGroup $group + * @return $this + */ + public function addGroup(IcingaObjectGroup $group) + { + $this->assertBeenLoadedFromDb($group); + $this->groups[$group->get('id')] = $group; + + $this->checked = false; + + return $this; + } + + /** + * @param IcingaObjectGroup[] $groups + * @return $this + */ + public function addGroups(array $groups) + { + foreach ($groups as $group) { + $this->addGroup($group); + } + + $this->checked = false; + + return $this; + } + + /** + * @param IcingaObjectGroup $group + * @return $this + */ + public function setGroup(IcingaObjectGroup $group) + { + $this->clearGroups(); + return $this->addGroup($group); + } + + /** + * @param array $groups + * @return $this + */ + public function setGroups(array $groups) + { + $this->clearGroups(); + return $this->addGroups($groups); + } + + /** + * @return $this + */ + public function clearGroups() + { + $this->objects = array(); + $this->checked = false; + return $this; + } + + public function getNewMappings() + { + if ($this->newMappings !== null && $this->existingMappings !== null) { + return $this->getDifference($this->newMappings, $this->existingMappings); + } else { + return []; + } + } + + /** + * @throws \Zend_Db_Adapter_Exception + */ + protected function storeNewMappings() + { + $diff = $this->getNewMappings(); + $count = count($diff); + if ($count === 0) { + return; + } + + $db = $this->db; + $this->beginTransaction(); + foreach ($diff as $row) { + $db->insert( + $this->getResolvedTableName(), + $row + ); + } + + $this->commit(); + Benchmark::measure( + sprintf( + 'Stored %d new resolved group memberships', + $count + ) + ); + } + + protected function getGroupId($name) + { + $type = $this->type; + if ($this->groupMap === null) { + $this->groupMap = $this->db->fetchPairs( + $this->db->select()->from("icinga_${type}group", ['object_name', 'id']) + ); + } + + if (array_key_exists($name, $this->groupMap)) { + return $this->groupMap[$name]; + } else { + throw new InvalidArgumentException(sprintf( + 'Unable to lookup the group name for "%s"', + $name + )); + } + } + + public function getOutdatedMappings() + { + if ($this->newMappings !== null && $this->existingMappings !== null) { + return $this->getDifference($this->existingMappings, $this->newMappings); + } else { + return []; + } + } + + protected function removeOutdatedMappings() + { + $diff = $this->getOutdatedMappings(); + $count = count($diff); + if ($count === 0) { + return; + } + + $type = $this->getType(); + $db = $this->db; + $this->beginTransaction(); + foreach ($diff as $row) { + $db->delete( + $this->getResolvedTableName(), + sprintf( + "(${type}group_id = %d AND ${type}_id = %d)", + $row["${type}group_id"], + $row["${type}_id"] + ) + ); + } + + $this->commit(); + Benchmark::measure( + sprintf( + 'Removed %d outdated group memberships', + $count + ) + ); + } + + protected function getDifference(&$left, &$right) + { + $diff = array(); + + $type = $this->getType(); + foreach ($left as $groupId => $objectIds) { + if (array_key_exists($groupId, $right)) { + foreach ($objectIds as $objectId) { + if (! array_key_exists($objectId, $right[$groupId])) { + $diff[] = array( + "${type}group_id" => $groupId, + "${type}_id" => $objectId, + ); + } + } + } else { + foreach ($objectIds as $objectId) { + $diff[] = array( + "${type}group_id" => $groupId, + "${type}_id" => $objectId, + ); + } + } + } + + return $diff; + } + + /** + * This fetches already resolved memberships + */ + protected function fetchStoredMappings() + { + $mappings = array(); + + $type = $this->getType(); + $query = $this->db->select()->from( + array('hgh' => $this->getResolvedTableName()), + array( + 'group_id' => "${type}group_id", + 'object_id' => "${type}_id", + ) + ); + + $this->addMembershipWhere($query, "${type}_id", $this->objects); + $this->addMembershipWhere($query, "${type}group_id", $this->groups); + if (! empty($this->groups)) { + // load staticGroups (we touched here) additionally, so we can compare changes + $this->addMembershipWhere($query, "${type}group_id", $this->staticGroups); + } + + foreach ($this->db->fetchAll($query) as $row) { + $groupId = $row->group_id; + $objectId = $row->object_id; + if (! array_key_exists($groupId, $mappings)) { + $mappings[$groupId] = array(); + } + + $mappings[$groupId][$objectId] = $objectId; + } + + $this->existingMappings = $mappings; + } + + /** + * @param ZfSelect $query + * @param string $column + * @param IcingaObject[]|int[] $objects + * @return ZfSelect + */ + protected function addMembershipWhere(ZfSelect $query, $column, &$objects) + { + if (empty($objects)) { + return $query; + } + + $ids = array(); + foreach ($objects as $k => $object) { + if (is_int($object)) { + $ids[] = $k; + } elseif (is_string($object)) { + $ids[] = (int) $object; + } else { + $ids[] = (int) $object->get('id'); + } + } + + if (count($ids) === 1) { + $query->orWhere($column . ' = ?', $ids[0]); + } else { + $query->orWhere($column . ' IN (?)', $ids); + } + + return $query; + } + + protected function recheckAllObjects($groups) + { + $mappings = []; + $staticGroups = []; + + if ($this->objects === null) { + $objects = $this->fetchAllObjects(); + } else { + $objects = & $this->objects; + } + + $times = array(); + + foreach ($objects as $object) { + if ($object->shouldBeRemoved()) { + continue; + } + if ($object->isTemplate()) { + continue; + } + + $mt = microtime(true); + $id = $object->get('id'); + + DynamicApplyMatches::setType($this->type); + $resolver = DynamicApplyMatches::prepare($object); + foreach ($groups as $groupId => $filter) { + if ($resolver->matchesFilter($filter)) { + if (! array_key_exists($groupId, $mappings)) { + $mappings[$groupId] = []; + } + $mappings[$groupId][$id] = $id; + } + } + + // can only be run reliably when updating for all groups + $groupNames = $object->get('groups'); + if (empty($groupNames)) { + $groupNames = $object->listInheritedGroupNames(); + } + foreach ($groupNames as $name) { + $groupId = $this->getGroupId($name); + if (! array_key_exists($groupId, $mappings)) { + $mappings[$groupId] = []; + } + + $mappings[$groupId][$id] = $id; + $staticGroups[$groupId] = $groupId; + } + + $times[] = (microtime(true) - $mt) * 1000; + } + + $count = count($times); + $min = $max = $avg = 0; + if ($count > 0) { + $min = min($times); + $max = max($times); + $avg = array_sum($times) / $count; + } + + Benchmark::measure(sprintf( + '%sgroup apply recalculated: objects=%d groups=%d min=%d max=%d avg=%d (in ms)', + $this->type, + $count, + count($groups), + $min, + $max, + $avg + )); + + Benchmark::measure('Done with single assignments'); + + $this->newMappings = $mappings; + $this->staticGroups = $staticGroups; + } + + protected function getAppliedGroups() + { + if (empty($this->groups)) { + return $this->fetchAppliedGroups(); + } else { + return $this->buildAppliedGroups(); + } + } + + protected function buildAppliedGroups() + { + $list = array(); + foreach ($this->groups as $id => $group) { + $list[$id] = $group->get('assign_filter'); + } + + return $this->parseFilters($list); + } + + protected function fetchAppliedGroups() + { + $type = $this->getType(); + $query = $this->db->select()->from( + array('hg' => "icinga_${type}group"), + array( + 'id', + 'assign_filter', + ) + )->where("assign_filter IS NOT NULL AND assign_filter != ''"); + + return $this->parseFilters($this->db->fetchPairs($query)); + } + + /** + * Parsing a list of query strings to Filter + * + * @param string[] $list List of query strings + * + * @return Filter[] + */ + protected function parseFilters($list) + { + return array_map(function ($s) { + return Filter::fromQueryString($s); + }, $list); + } + + protected function getTableName() + { + $type = $this->getType(); + return "icinga_${type}group_${type}"; + } + + protected function getResolvedTableName() + { + return $this->getTableName() . '_resolved'; + } + + /** + * @return $this + */ + protected function beginTransaction() + { + if ($this->useTransactions) { + $this->db->beginTransaction(); + } + + return $this; + } + + /** + * @return $this + */ + protected function commit() + { + if ($this->useTransactions) { + $this->db->commit(); + } + + return $this; + } + + /** + * @return IcingaObject[] + */ + protected function getObjects() + { + if ($this->objects === null) { + $this->objects = $this->fetchAllObjects(); + } + + return $this->objects; + } + + protected function fetchAllObjects() + { + return IcingaObject::loadAllByType($this->getType(), $this->connection); + } + + protected function assertBeenLoadedFromDb(IcingaObject $object) + { + if (! is_int($object->get('id')) && ! ctype_digit($object->get('id'))) { + throw new LogicException( + 'Group resolver does not support unstored objects' + ); + } + } +} diff --git a/library/Director/Objects/HostApplyMatches.php b/library/Director/Objects/HostApplyMatches.php new file mode 100644 index 0000000..5feaee7 --- /dev/null +++ b/library/Director/Objects/HostApplyMatches.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class HostApplyMatches extends ObjectApplyMatches +{ + protected static $type = 'host'; +} diff --git a/library/Director/Objects/HostGroupMembershipResolver.php b/library/Director/Objects/HostGroupMembershipResolver.php new file mode 100644 index 0000000..b597017 --- /dev/null +++ b/library/Director/Objects/HostGroupMembershipResolver.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class HostGroupMembershipResolver extends GroupMembershipResolver +{ + protected $type = 'host'; +} diff --git a/library/Director/Objects/IcingaApiUser.php b/library/Director/Objects/IcingaApiUser.php new file mode 100644 index 0000000..bb4f9f8 --- /dev/null +++ b/library/Director/Objects/IcingaApiUser.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; + +class IcingaApiUser extends IcingaObject +{ + protected $table = 'icinga_apiuser'; + + protected $uuidColumn = 'uuid'; + + // TODO: Enable (and add table) if required + protected $supportsImports = false; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'password' => null, + 'client_dn' => null, + 'permissions' => null, + ]; + + protected function renderPassword() + { + return c::renderKeyValue('password', c::renderString('***')); + } +} diff --git a/library/Director/Objects/IcingaArguments.php b/library/Director/Objects/IcingaArguments.php new file mode 100644 index 0000000..e788da8 --- /dev/null +++ b/library/Director/Objects/IcingaArguments.php @@ -0,0 +1,442 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Countable; +use Exception; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use InvalidArgumentException; +use Iterator; + +class IcingaArguments implements Iterator, Countable, IcingaConfigRenderer +{ + const COMMENT_DSL_UNSUPPORTED = '/* Icinga 2 does not export DSL function bodies via API */'; + + /** @var IcingaCommandArgument[] */ + protected $storedArguments = []; + + /** @var IcingaCommandArgument[] */ + protected $arguments = []; + + protected $modified = false; + + protected $object; + + private $position = 0; + + protected $idx = []; + + public function __construct(IcingaObject $object) + { + $this->object = $object; + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->arguments); + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + public function hasBeenModified() + { + return $this->modified; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->arguments[$this->idx[$this->position]]; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + public function get($key) + { + if (array_key_exists($key, $this->arguments)) { + if ($this->arguments[$key]->shouldBeRemoved()) { + return null; + } + + return $this->arguments[$key]; + } + + return null; + } + + public function set($key, $value) + { + if ($value === null) { + return $this->remove($key); + } + + if ($value instanceof IcingaCommandArgument) { + $argument = $value; + } else { + $argument = IcingaCommandArgument::create( + $this->mungeCommandArgument($key, $value) + ); + } + + $argument->set('command_id', $this->object->get('id')); + + $key = $argument->argument_name; + if (array_key_exists($key, $this->arguments)) { + $this->arguments[$key]->replaceWith($argument); + if ($this->arguments[$key]->hasBeenModified()) { + $this->modified = true; + } + } elseif (array_key_exists($key, $this->storedArguments)) { + $this->arguments[$key] = clone($this->storedArguments[$key]); + $this->arguments[$key]->replaceWith($argument); + if ($this->arguments[$key]->hasBeenModified()) { + $this->modified = true; + } + } else { + $this->add($argument); + $this->modified = true; + } + + return $this; + } + + protected function mungeCommandArgument($key, $value) + { + $attrs = [ + 'argument_name' => (string) $key, + ]; + + $map = [ + 'skip_key' => 'skip_key', + 'repeat_key' => 'repeat_key', + 'required' => 'required', + // 'order' => 'sort_order', + 'description' => 'description', + 'set_if' => 'set_if', + ]; + + $argValue = null; + if (is_object($value)) { + if (property_exists($value, 'order')) { + $attrs['sort_order'] = (string) $value->order; + } + + foreach ($map as $apiKey => $dbKey) { + if (property_exists($value, $apiKey)) { + $attrs[$dbKey] = $value->$apiKey; + } + } + if (property_exists($value, 'type')) { + // argument is directly set as function, no further properties + if ($value->type === 'Function') { + $attrs['argument_value'] = self::COMMENT_DSL_UNSUPPORTED; + $attrs['argument_format'] = 'expression'; + } + } elseif (property_exists($value, 'value')) { + // argument is a dictionary with further settings + if (is_object($value->value)) { + if ($value->value->type === 'Function' && property_exists($value->value, 'body')) { + // likely an export from Baskets that contains the actual function body + $attrs['argument_value'] = $value->value->body; + $attrs['argument_format'] = 'expression'; + } elseif ($value->value->type === 'Function') { + $attrs['argument_value'] = self::COMMENT_DSL_UNSUPPORTED; + $attrs['argument_format'] = 'expression'; + } else { + die('Unable to resolve command argument'); + } + } else { + $argValue = $value->value; + if (is_string($argValue)) { + $attrs['argument_value'] = $argValue; + $attrs['argument_format'] = 'string'; + } else { + $attrs['argument_value'] = $argValue; + $attrs['argument_format'] = 'json'; + } + } + } + } else { + if (is_string($value)) { + $attrs['argument_value'] = $value; + $attrs['argument_format'] = 'string'; + } else { + $attrs['argument_value'] = $value; + $attrs['argument_format'] = 'json'; + } + } + + if (array_key_exists('set_if', $attrs)) { + if (is_object($attrs['set_if']) && $attrs['set_if']->type === 'Function') { + $attrs['set_if'] = self::COMMENT_DSL_UNSUPPORTED; + $attrs['set_if_format'] = 'expression'; + } elseif (property_exists($value, 'set_if_format')) { + if (in_array($value->set_if_format, ['string', 'expression', 'json'])) { + $attrs['set_if_format'] = $value->set_if_format; + } + } + } + + return $attrs; + } + + public function setArguments($arguments) + { + $arguments = (array) $arguments; + + foreach ($arguments as $arg => $val) { + $this->set($arg, $val); + } + + foreach (array_diff( + array_keys($this->arguments), + array_keys($arguments) + ) as $arg) { + if ($this->arguments[$arg]->hasBeenLoadedFromDb()) { + $this->arguments[$arg]->markForRemoval(); + $this->modified = true; + } else { + unset($this->arguments[$arg]); + } + } + + return $this; + } + + /** + * Magic isset check + * + * @param string $argument + * @return boolean + */ + public function __isset($argument) + { + return array_key_exists($argument, $this->arguments); + } + + public function remove($argument) + { + if (array_key_exists($argument, $this->arguments)) { + $this->arguments[$argument]->markForRemoval(); + $this->modified = true; + $this->refreshIndex(); + } + + return $this; + } + + protected function refreshIndex() + { + ksort($this->arguments); + $this->idx = array_keys($this->arguments); + } + + public function add(IcingaCommandArgument $argument) + { + $name = $argument->get('argument_name'); + if (array_key_exists($name, $this->arguments)) { + // TODO: Fail unless $argument equals existing one + return $this; + } + + $this->arguments[$name] = $argument; + $this->modified = true; + $this->refreshIndex(); + + return $this; + } + + protected function getGroupTableName() + { + return $this->object->getTableName() . 'group'; + } + + protected function loadFromDb() + { + $db = $this->object->getDb(); + $connection = $this->object->getConnection(); + + $table = $this->object->getTableName(); + $query = $db->select()->from( + ['o' => $table], + [] + )->join( + ['a' => 'icinga_command_argument'], + 'o.id = a.command_id', + '*' + )->where('o.object_name = ?', $this->object->getObjectName()) + ->order('a.sort_order')->order('a.argument_name'); + + $this->arguments = IcingaCommandArgument::loadAll($connection, $query, 'argument_name'); + $this->cloneStored(); + $this->refreshIndex(); + + return $this; + } + + public function toPlainObject( + $resolved = false, + $skipDefaults = false, + array $chosenProperties = null, + $resolveIds = true + ) { + if ($chosenProperties !== null) { + throw new InvalidArgumentException( + 'IcingaArguments does not support chosenProperties[]' + ); + } + + $args = []; + foreach ($this->arguments as $arg) { + if ($arg->shouldBeRemoved()) { + continue; + } + + $args[$arg->get('argument_name')] = $arg->toPlainObject( + $resolved, + $skipDefaults, + null, + $resolveIds + ); + } + + return $args; + } + + public function toUnmodifiedPlainObject() + { + $args = []; + foreach ($this->storedArguments as $key => $arg) { + $args[$arg->argument_name] = $arg->toPlainObject(); + } + + return $args; + } + + protected function cloneStored() + { + $this->storedArguments = []; + foreach ($this->arguments as $k => $v) { + $this->storedArguments[$k] = clone($v); + } + } + + public static function loadForStoredObject(IcingaObject $object) + { + $arguments = new static($object); + return $arguments->loadFromDb(); + } + + public function setBeingLoadedFromDb() + { + foreach ($this->arguments as $argument) { + $argument->setBeingLoadedFromDb(); + } + $this->refreshIndex(); + $this->cloneStored(); + } + + /** + * @return $this + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function store() + { + $db = $this->object->getConnection(); + $deleted = []; + foreach ($this->arguments as $key => $argument) { + if ($argument->shouldBeRemoved()) { + $deleted[] = $key; + } else { + if ($argument->hasBeenModified()) { + if ($argument->hasBeenLoadedFromDb()) { + $argument->setLoadedProperty('command_id', $this->object->get('id')); + } else { + $argument->set('command_id', $this->object->get('id')); + } + $argument->store($db); + } + } + } + + foreach ($deleted as $key) { + $argument = $this->arguments[$key]; + $argument->setLoadedProperty('command_id', $this->object->get('id')); + $argument->setConnection($this->object->getConnection()); + $argument->delete(); + unset($this->arguments[$key]); + } + + $this->cloneStored(); + + return $this; + } + + public function toConfigString() + { + if (empty($this->arguments)) { + return ''; + } + + $args = []; + foreach ($this->arguments as $arg) { + if ($arg->shouldBeRemoved()) { + continue; + } + + $args[$arg->get('argument_name')] = $arg->toConfigString(); + } + return c::renderKeyOperatorValue('arguments', '+=', c::renderDictionary($args)); + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } else { + die($e->getMessage()); + } + } + } + + public function toLegacyConfigString() + { + return 'UNSUPPORTED'; + } +} diff --git a/library/Director/Objects/IcingaCommand.php b/library/Director/Objects/IcingaCommand.php new file mode 100644 index 0000000..35f38a4 --- /dev/null +++ b/library/Director/Objects/IcingaCommand.php @@ -0,0 +1,365 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\Objects\Extension\Arguments; +use Zend_Db_Select as DbSelect; + +class IcingaCommand extends IcingaObject implements ObjectWithArguments, ExportInterface +{ + use Arguments; + + protected $table = 'icinga_command'; + + protected $type = 'CheckCommand'; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'methods_execute' => null, + 'command' => null, + 'timeout' => null, + 'zone_id' => null, + 'is_string' => null, + ]; + + protected $booleans = [ + 'is_string' => 'is_string', + ]; + + protected $supportsCustomVars = true; + + protected $supportsFields = true; + + protected $supportsImports = true; + + protected $supportedInLegacy = true; + + protected $intervalProperties = [ + 'timeout' => 'timeout', + ]; + + protected $relations = [ + 'zone' => 'IcingaZone', + ]; + + protected static $pluginDir; + + protected $hiddenExecuteTemplates = [ + 'PluginCheck' => 'plugin-check-command', + 'PluginNotification' => 'plugin-notification-command', + 'PluginEvent' => 'plugin-event-command', + + // Special, internal: + 'IcingaCheck' => 'icinga-check-command', + 'ClusterCheck' => 'cluster-check-command', + 'ClusterZoneCheck' => 'plugin-check-command', + 'IdoCheck' => 'ido-check-command', + 'RandomCheck' => 'random-check-command', + ]; + + /** + * Render the 'medhods_execute' property as 'execute' + * + * Execute is a reserved word in SQL, column name was prefixed + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderMethods_execute() + { + // @codingStandardsIgnoreEnd + return ''; + } + + protected function renderObjectHeader() + { + if ($execute = $this->get('methods_execute')) { + $itlImport = sprintf( + ' import "%s"' . "\n", + $this->hiddenExecuteTemplates[$execute] + ); + } else { + $itlImport = ''; + } + + $execute = $this->getSingleResolvedProperty('methods_execute'); + if ($execute === 'PluginNotification') { + return $this->renderObjectHeaderWithType('NotificationCommand') . $itlImport; + } elseif ($execute === 'PluginEvent') { + return $this->renderObjectHeaderWithType('EventCommand') . $itlImport; + } else { + return parent::renderObjectHeader() . $itlImport; + } + } + + /** + * @param $type + * @return string + */ + protected function renderObjectHeaderWithType($type) + { + return sprintf( + "%s %s %s {\n", + $this->getObjectTypeName(), + $type, + c::renderString($this->getObjectName()) + ); + } + + public function mungeCommand($value) + { + if (is_array($value)) { + $value = implode(' ', $value); + } elseif (is_object($value)) { + // { type => Function } -> really?? + return null; + // return $value; + } + + if (self::$pluginDir !== null) { + if (($pos = strpos($value, self::$pluginDir)) === 0) { + $value = substr($value, strlen(self::$pluginDir) + 1); + } + } + + return $value; + } + + public function getNextSkippableKeyName() + { + $key = $this->makeSkipKey(); + $cnt = 1; + while (isset($this->arguments()->$key)) { + $cnt++; + $key = $this->makeSkipKey($cnt); + } + + return $key; + } + + protected function makeSkipKey($num = null) + { + if ($num === null) { + return '(no key)'; + } + + return sprintf('(no key.%d)', $num); + } + + protected function prefersGlobalZone() + { + return true; + } + + /** + * @return string + * @throws \Zend_Db_Select_Exception + */ + public function countDirectUses() + { + $db = $this->getDb(); + $id = (int) $this->get('id'); + + $qh = $db->select()->from( + array('h' => 'icinga_host'), + array('cnt' => 'COUNT(*)') + )->where('h.check_command_id = ?', $id) + ->orWhere('h.event_command_id = ?', $id); + $qs = $db->select()->from( + array('s' => 'icinga_service'), + array('cnt' => 'COUNT(*)') + )->where('s.check_command_id = ?', $id) + ->orWhere('s.event_command_id = ?', $id); + $qn = $db->select()->from( + array('n' => 'icinga_notification'), + array('cnt' => 'COUNT(*)') + )->where('n.command_id = ?', $id); + $query = $db->select()->union( + [$qh, $qs, $qn], + DbSelect::SQL_UNION_ALL + ); + + return $db->fetchOne($db->select()->from( + ['all_cnts' => $query], + ['cnt' => 'SUM(cnt)'] + )); + } + + /** + * @return bool + * @throws \Zend_Db_Select_Exception + */ + public function isInUse() + { + return $this->countDirectUses() > 0; + } + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + $props = (array) $this->toPlainObject(); + if (isset($props['arguments'])) { + foreach ($props['arguments'] as $key => $argument) { + if (property_exists($argument, 'command_id')) { + unset($props['arguments'][$key]->command_id); + } + } + } + $props['fields'] = $this->loadFieldReferences(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaCommand + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Command "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + unset($properties['fields']); + $object->setProperties($properties); + + return $object; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return array + */ + protected function loadFieldReferences() + { + $db = $this->getDb(); + + $res = $db->fetchAll( + $db->select()->from([ + 'cf' => 'icinga_command_field' + ], [ + 'cf.datafield_id', + 'cf.is_required', + 'cf.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = cf.datafield_id', []) + ->where('command_id = ?', $this->get('id')) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } else { + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + + return $res; + } + } + + protected function renderCommand() + { + $command = $this->get('command'); + $prefix = ''; + if (preg_match('~^([A-Z][A-Za-z0-9_]+\s\+\s)(.+?)$~', $command, $m)) { + $prefix = $m[1]; + $command = $m[2]; + } elseif (! $this->isAbsolutePath($command)) { + $prefix = 'PluginDir + '; + $command = '/' . $command; + } + + $inherited = $this->getInheritedProperties(); + + if ($this->get('is_string') === 'y' || ($this->get('is_string') === null + && property_exists($inherited, 'is_string') && $inherited->is_string === 'y')) { + return c::renderKeyValue('command', $prefix . c::renderString($command)); + } else { + $parts = preg_split('/\s+/', $command, -1, PREG_SPLIT_NO_EMPTY); + array_unshift($parts, c::alreadyRendered($prefix . c::renderString(array_shift($parts)))); + + return c::renderKeyValue('command', c::renderArray($parts)); + } + } + + /** + * @codingStandardsIgnoreStart + */ + protected function renderIs_string() + { + // @codingStandardsIgnoreEnd + return ''; + } + + protected function isAbsolutePath($path) + { + return $path[0] === '/' + || $path[0] === '\\' + || preg_match('/^[A-Za-z]:\\\/', substr($path, 0, 3)) + || preg_match('/^%[A-Z][A-Za-z0-9\(\)-]*%/', $path); + } + + public static function setPluginDir($pluginDir) + { + self::$pluginDir = $pluginDir; + } + + public function getLegacyObjectType() + { + // there is only one type of command in Icinga 1.x + return 'command'; + } + + protected function renderLegacyCommand() + { + $command = $this->get('command'); + if (preg_match('~^(\$USER\d+\$/?)(.+)$~', $command)) { + // should be fine, since the user decided to use a macro + } elseif (! $this->isAbsolutePath($command)) { + $command = '$USER1$/'.$command; + } + + return c1::renderKeyValue( + $this->getLegacyObjectType().'_line', + c1::renderString($command) + ); + } +} diff --git a/library/Director/Objects/IcingaCommandArgument.php b/library/Director/Objects/IcingaCommandArgument.php new file mode 100644 index 0000000..96101ce --- /dev/null +++ b/library/Director/Objects/IcingaCommandArgument.php @@ -0,0 +1,263 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use RuntimeException; + +class IcingaCommandArgument extends IcingaObject +{ + protected $keyName = ['command_id', 'argument_name']; + + protected $autoincKeyName = 'id'; + + protected $table = 'icinga_command_argument'; + + protected $supportsImports = false; + + protected $booleans = array( + 'skip_key' => 'skip_key', + 'repeat_key' => 'repeat_key', + 'required' => 'required' + ); + + protected $defaultProperties = array( + 'id' => null, + 'command_id' => null, + 'argument_name' => null, + 'argument_value' => null, + 'argument_format' => null, + 'key_string' => null, + 'description' => null, + 'skip_key' => null, + 'set_if' => null, + 'sort_order' => null, + 'repeat_key' => null, + 'set_if_format' => null, + 'required' => null, + ); + + public function onInsert() + { + // No log right now, we have to handle "sub-objects" + } + + public function onUpdate() + { + // No log right now, we have to handle "sub-objects" + } + + public function onDelete() + { + // No log right now, we have to handle "sub-objects" + } + + public function isSkippingKey() + { + return $this->get('skip_key') === 'y' || $this->get('argument_name') === null; + } + + // Preserve is not supported + public function replaceWith(IcingaObject $object, $preserve = null) + { + $this->setProperties((array) $object->toPlainObject( + false, + false, + null, + false + )); + return $this; + } + + protected function makePlainArgumentValue($value, $format) + { + if ($format === 'expression') { + return (object) [ + 'type' => 'Function', + // TODO: Not for dummy comment + 'body' => $value + ]; + } else { + // json or string + return $value; + } + } + + protected function extractValueFromPlain($plain) + { + if ($plain->argument_value) { + return $this->makePlainArgumentValue( + $plain->argument_value, + $plain->argument_format + ); + } else { + return null; + } + } + + protected function transformPlainArgumentValue($plain) + { + if (property_exists($plain, 'argument_value')) { + if (property_exists($plain, 'argument_format')) { + $format = $plain->argument_format; + } else { + $format = 'string'; + } + $plain->value = $this->makePlainArgumentValue( + $plain->argument_value, + $format + ); + unset($plain->argument_value); + unset($plain->argument_format); + } + } + + public function toCompatPlainObject() + { + $plain = parent::toPlainObject( + false, + true, + null, + false + ); + + unset($plain->id); + unset($plain->argument_name); + if (! isset($plain->argument_value)) { + unset($plain->argument_format); + } + if (! isset($plain->set_if)) { + unset($plain->set_if_format); + } + + $this->transformPlainArgumentValue($plain); + unset($plain->command_id); + + // Will happen only combined with $skipDefaults + if (array_keys((array) $plain) === ['value']) { + return $plain->value; + } else { + if (property_exists($plain, 'sort_order') && $plain->sort_order !== null) { + $plain->order = $plain->sort_order; + unset($plain->sort_order); + } + + return $plain; + } + } + + public function toFullPlainObject($skipDefaults = false) + { + $plain = parent::toPlainObject( + false, + $skipDefaults, + null, + false + ); + + unset($plain->id); + + return $plain; + } + + public function toPlainObject( + $resolved = false, + $skipDefaults = false, + array $chosenProperties = null, + $resolveIds = true, + $keepId = false + ) { + if ($resolved) { + throw new RuntimeException( + 'A single CommandArgument cannot be resolved' + ); + } + + if ($chosenProperties) { + throw new RuntimeException( + 'IcingaCommandArgument does not support chosenProperties[]' + ); + } + + if ($keepId) { + throw new RuntimeException( + 'IcingaCommandArgument does not support $keepId' + ); + } + + // $resolveIds is misused here + if ($resolveIds) { + return $this->toCompatPlainObject(); + } else { + return $this->toFullPlainObject($skipDefaults); + } + } + + public function toConfigString() + { + $data = array(); + $value = $this->get('argument_value'); + if ($value) { + switch ($this->get('argument_format')) { + case 'string': + $data['value'] = c::renderString($value); + break; + case 'json': + if (is_object($value)) { + $data['value'] = c::renderDictionary($value); + } elseif (is_array($value)) { + $data['value'] = c::renderArray($value); + } elseif (is_null($value)) { + // TODO: recheck all this. I bet we never reach this: + $data['value'] = 'null'; + } elseif (is_bool($value)) { + $data['value'] = c::renderBoolean($value); + } else { + $data['value'] = $value; + } + break; + case 'expression': + $data['value'] = c::renderExpression($value); + break; + } + } + + if ($this->get('sort_order') !== null) { + $data['order'] = $this->get('sort_order'); + } + + if (null !== $this->get('set_if')) { + switch ($this->get('set_if_format')) { + case 'expression': + $data['set_if'] = c::renderExpression($this->get('set_if')); + break; + case 'string': + default: + $data['set_if'] = c::renderString($this->get('set_if')); + break; + } + } + + if (null !== $this->get('required')) { + $data['required'] = c::renderBoolean($this->get('required')); + } + + if ($this->isSkippingKey()) { + $data['skip_key'] = c::renderBoolean('y'); + } + + if (null !== $this->get('repeat_key')) { + $data['repeat_key'] = c::renderBoolean($this->get('repeat_key')); + } + + if (null !== $this->get('description')) { + $data['description'] = c::renderString($this->get('description')); + } + + if (array_keys($data) === ['value']) { + return $data['value']; + } else { + return c::renderDictionary($data); + } + } +} diff --git a/library/Director/Objects/IcingaCommandField.php b/library/Director/Objects/IcingaCommandField.php new file mode 100644 index 0000000..086cb56 --- /dev/null +++ b/library/Director/Objects/IcingaCommandField.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaCommandField extends IcingaObjectField +{ + protected $keyName = array('command_id', 'datafield_id'); + + protected $table = 'icinga_command_field'; + + protected $defaultProperties = array( + 'command_id' => null, + 'datafield_id' => null, + 'is_required' => null, + 'var_filter' => null, + ); +} diff --git a/library/Director/Objects/IcingaDependency.php b/library/Director/Objects/IcingaDependency.php new file mode 100644 index 0000000..c9d9b89 --- /dev/null +++ b/library/Director/Objects/IcingaDependency.php @@ -0,0 +1,631 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Exception\NotFoundError; +use Icinga\Data\Filter\Filter; + +class IcingaDependency extends IcingaObject implements ExportInterface +{ + protected $table = 'icinga_dependency'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'apply_to' => null, + 'parent_host_id' => null, + 'parent_host_var' => null, + 'parent_service_id' => null, + 'child_host_id' => null, + 'child_service_id' => null, + 'disable_checks' => null, + 'disable_notifications' => null, + 'ignore_soft_states' => null, + 'period_id' => null, + 'zone_id' => null, + 'assign_filter' => null, + 'parent_service_by_name' => null, + ]; + + protected $uuidColumn = 'uuid'; + + protected $supportsCustomVars = false; + + protected $supportsImports = true; + + protected $supportsApplyRules = true; + + /** + * @internal + * @var bool + */ + protected $renderApplyForArray = false; + + protected $relatedSets = [ + 'states' => 'StateFilterSet', + ]; + + protected $relations = [ + 'zone' => 'IcingaZone', + 'parent_host' => 'IcingaHost', + 'parent_service' => 'IcingaService', + 'child_host' => 'IcingaHost', + 'child_service' => 'IcingaService', + 'period' => 'IcingaTimePeriod', + ]; + + protected $booleans = [ + 'disable_checks' => 'disable_checks', + 'disable_notifications' => 'disable_notifications', + 'ignore_soft_states' => 'ignore_soft_states' + ]; + + protected $propertiesNotForRendering = [ + 'id', + 'object_name', + 'object_type', + 'apply_to', + ]; + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + $props = (array) $this->toPlainObject(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Dependency "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $object->setProperties($properties); + + return $object; + } + + public function parentHostIsVar() + { + return $this->get('parent_host_var') !== null; + } + + /** + * @return string + * @throws ConfigurationError + */ + protected function renderObjectHeader() + { + if ($this->isApplyRule()) { + if (($to = $this->get('apply_to')) === null) { + throw new ConfigurationError( + 'Applied dependency "%s" has no valid object type', + $this->getObjectName() + ); + } + + if ($this->renderApplyForArray) { + return $this->renderArrayObjectHeader($to); + } + + return $this->renderSingleObjectHeader($to); + } + + return parent::renderObjectHeader(); + } + + protected function renderSingleObjectHeader($to) + { + return sprintf( + "%s %s %s to %s {\n", + $this->getObjectTypeName(), + $this->getType(), + c::renderString($this->getObjectName()), + ucfirst($to) + ); + } + + protected function renderArrayObjectHeader($to) + { + return sprintf( + "%s %s %s for (host_parent_name in %s) to %s {\n", + $this->getObjectTypeName(), + $this->getType(), + c::renderString($this->getObjectName()), + $this->get('parent_host_var'), + ucfirst($to) + ); + } + + /** + * @return string + */ + protected function renderSuffix() + { + if (! $this->parentHostIsVar()) { + return parent::renderSuffix(); + } + + if ((string) $this->get('assign_filter') !== '') { + $suffix = parent::renderSuffix(); + } else { + $suffix = ' assign where ' . $this->renderAssignFilterExtension('') + . "\n" . parent::renderSuffix(); + } + + if ($this->renderApplyForArray) { + return $suffix; + } + + return $suffix . $this->renderApplyForArrayClone(); + } + + protected function renderApplyForArrayClone() + { + $clone = clone($this); + $clone->renderApplyForArray = true; + + return $clone->toConfigString(); + } + + public function isApplyForArrayClone() + { + return $this->renderApplyForArray; + } + + /** + * @codingStandardsIgnoreStart + */ + public function renderAssign_Filter() + { + if ($this->parentHostIsVar()) { + return preg_replace( + '/\n$/m', + $this->renderAssignFilterExtension() . "\n", + parent::renderAssign_Filter() + ); + } + + return parent::renderAssign_Filter(); + } + + protected function renderAssignFilterExtension($pre = ' && ') + { + $varName = $this->get('parent_host_var'); + if ($this->renderApplyForArray) { + return sprintf('%stypeof(%s) == Array', $pre, $varName); + } + + return sprintf('%stypeof(%s) == String', $pre, $varName); + } + + protected function setKey($key) + { + // TODO: Check if this method can be removed + if (is_int($key)) { + $this->id = $key; + } elseif (is_array($key)) { + $keys = [ + 'id', + 'parent_host_id', + 'parent_service_id', + 'child_host_id', + 'child_service_id', + 'object_name' + ]; + + foreach ($keys as $k) { + if (array_key_exists($k, $key)) { + $this->set($k, $key[$k]); + } + } + } else { + return parent::setKey($key); + } + + return $this; + } + + protected function renderAssignments() + { + // TODO: this will never be reached + if ($this->hasBeenAssignedToServiceApply()) { + /** @var IcingaService $tmpService */ + $tmpService = $this->getRelatedObject( + 'child_service', + $this->get('child_service_id') + ); + // TODO: fix this, will crash: + $assigns = $tmpService->assignments()->toConfigString(); + + $filter = sprintf( + '%s && service.name == "%s"', + trim($assigns), + $this->get('child_service') + ); + return "\n " . $filter . "\n"; + } + + if ($this->hasBeenAssignedToHostTemplateService()) { + $filter = sprintf( + 'assign where "%s" in host.templates && service.name == "%s"', + $this->get('child_host'), + $this->get('child_service') + ); + return "\n " . $filter . "\n"; + } + if ($this->hasBeenAssignedToHostTemplate()) { + $filter = sprintf( + 'assign where "%s" in host.templates', + $this->get('child_host') + ); + return "\n " . $filter . "\n"; + } + + if ($this->hasBeenAssignedToServiceTemplate()) { + $filter = sprintf( + 'assign where "%s" in service.templates', + $this->get('child_service') + ); + return "\n " . $filter . "\n"; + } + + return parent::renderAssignments(); + } + + protected function hasBeenAssignedToHostTemplate() + { + try { + $id = $this->get('child_host_id'); + return $id && $this->getRelatedObject( + 'child_host', + $id + )->isTemplate(); + } catch (NotFoundError $e) { + return false; + } + } + + protected function hasBeenAssignedToServiceTemplate() + { + try { + $id = $this->get('child_service_id'); + return $id && $this->getRelatedObject( + 'child_service', + $id + )->isTemplate(); + } catch (NotFoundError $e) { + return false; + } + } + + protected function hasBeenAssignedToHostTemplateService() + { + if (!$this->hasBeenAssignedToHostTemplate()) { + return false; + } + try { + $id = $this->get('child_service_id'); + return $id && $this->getRelatedObject( + 'child_service', + $id + )->isObject(); + } catch (NotFoundError $e) { + return false; + } + } + + protected function hasBeenAssignedToServiceApply() + { + try { + $id = $this->get('child_service_id'); + return $id && $this->getRelatedObject( + 'child_service', + $id + )->isApplyRule(); + } catch (NotFoundError $e) { + return false; + } + } + + /** + * Render child_host_id as host_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderChild_host_id() + { + // @codingStandardsIgnoreEnd + + if ($this->hasBeenAssignedToHostTemplate()) { + return ''; + } + + return $this->renderRelationProperty( + 'child_host', + $this->get('child_host_id'), + 'child_host_name' + ); + } + + /** + * Render parent_host_id as parent_host_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderParent_host_id() + { + // @codingStandardsIgnoreEnd + return $this->renderRelationProperty( + 'parent_host', + $this->get('parent_host_id'), + 'parent_host_name' + ); + } + + /** + * Render parent_host_var as parent_host + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderParent_host_var() + { + // @codingStandardsIgnoreEnd + if ($this->renderApplyForArray) { + return c::renderKeyValue( + 'parent_host_name', + 'host_parent_name' + ); + } + + // @codingStandardsIgnoreEnd + return c::renderKeyValue( + 'parent_host_name', + $this->get('parent_host_var') + ); + } + + /** + * Render child_service_id as host_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderChild_service_id() + { + // @codingStandardsIgnoreEnd + if ($this->hasBeenAssignedToServiceTemplate() + || $this->hasBeenAssignedToHostTemplateService() + || $this->hasBeenAssignedToServiceApply() + ) { + return ''; + } + + return $this->renderRelationProperty( + 'child_service', + $this->get('child_service_id'), + 'child_service_name' + ); + } + + /** + * Render parent_service_id as parent_service_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderParent_service_id() + { + return $this->renderRelationProperty( + 'parent_service', + $this->get('parent_service_id'), + 'parent_service_name' + ); + } + + // + /** + * Render parent_service_by_name as parent_service_name + * + * Special case for parent service set as plain string for Apply rules + * + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderParent_service_by_name() + { + // @codingStandardsIgnoreEnd + return c::renderKeyValue( + 'parent_service_name', + c::renderString($this->get('parent_service_by_name')) + ); + } + + public function isApplyRule() + { + if ($this->hasBeenAssignedToHostTemplate() + || $this->hasBeenAssignedToServiceTemplate() + || $this->hasBeenAssignedToServiceApply() + ) { + return true; + } + + return parent::isApplyRule(); + } + + protected function resolveUnresolvedRelatedProperty($name) + { + $short = substr($name, 0, -3); + /** @var IcingaObject $class */ + $class = $this->getRelationClass($short); + $objKey = $this->unresolvedRelatedProperties[$name]; + + # related services need array key + if ($class === IcingaService::class) { + if ($name === 'parent_service_id' && $this->get('object_type') === 'apply') { + //special case , parent service can be set as simple string for Apply + if ($this->properties['parent_host_id'] === null) { + $this->reallySet( + 'parent_service_by_name', + $this->unresolvedRelatedProperties[$name] + ); + $this->reallySet('parent_service_id', null); + unset($this->unresolvedRelatedProperties[$name]); + return; + } + } + + $this->reallySet('parent_service_by_name', null); + $hostIdProperty = str_replace('service', 'host', $name); + if (isset($this->properties[$hostIdProperty])) { + $objKey = [ + 'host_id' => $this->properties[$hostIdProperty], + 'object_name' => $this->unresolvedRelatedProperties[$name] + ]; + } else { + $objKey = [ + 'host_id' => null, + 'object_name' => $this->unresolvedRelatedProperties[$name] + ]; + } + + try { + $class::load($objKey, $this->connection); + } catch (NotFoundError $e) { + // Not a simple service on host + // Hunt through inherited services, use service assigned to + // template if found + $tmpHost = IcingaHost::loadWithAutoIncId( + $this->properties[$hostIdProperty], + $this->connection + ); + + // services for applicable templates + $resolver = $tmpHost->templateResolver(); + foreach ($resolver->fetchResolvedParents() as $template_obj) { + $objKey = [ + 'host_id' => $template_obj->id, + 'object_name' => $this->unresolvedRelatedProperties[$name] + ]; + try { + $object = $class::load($objKey, $this->connection); + } catch (NotFoundError $e) { + continue; + } + break; + } + + if (!isset($object)) { + // Not an inherited service, now try apply rules + $matcher = HostApplyMatches::prepare($tmpHost); + foreach ($this->getAllApplyRules() as $rule) { + if ($matcher->matchesFilter($rule->filter)) { + if ($rule->name === $this->unresolvedRelatedProperties[$name]) { + $object = IcingaService::loadWithAutoIncId( + $rule->id, + $this->connection + ); + break; + } + } + } + } + } + } else { + $object = $class::load($objKey, $this->connection); + } + + if (isset($object)) { + $this->reallySet($name, $object->get('id')); + unset($this->unresolvedRelatedProperties[$name]); + } else { + throw new NotFoundError('Unable to resolve related property: "%s"', $name); + } + } + + protected function getAllApplyRules() + { + $allApplyRules = $this->fetchAllApplyRules(); + foreach ($allApplyRules as $rule) { + $rule->filter = Filter::fromQueryString($rule->assign_filter); + } + + return $allApplyRules; + } + + protected function fetchAllApplyRules() + { + $db = $this->connection->getDbAdapter(); + $query = $db->select()->from(['s' => 'icinga_service'], [ + 'id' => 's.id', + 'name' => 's.object_name', + 'assign_filter' => 's.assign_filter', + ])->where('object_type = ? AND assign_filter IS NOT NULL', 'apply'); + + return $db->fetchAll($query); + } + + protected function getRelatedProperty($key) + { + $related = parent::getRelatedProperty($key); + // handle special case for plain string parent service on Dependency + // Apply rules + if ($related === null && $key === 'parent_service' + && null !== $this->get('parent_service_by_name') + ) { + return $this->get('parent_service_by_name'); + } + + return $related; + } +} diff --git a/library/Director/Objects/IcingaEndpoint.php b/library/Director/Objects/IcingaEndpoint.php new file mode 100644 index 0000000..030183b --- /dev/null +++ b/library/Director/Objects/IcingaEndpoint.php @@ -0,0 +1,157 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Core\LegacyDeploymentApi; +use Icinga\Module\Director\Core\RestApiClient; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use InvalidArgumentException; +use RuntimeException; + +class IcingaEndpoint extends IcingaObject +{ + protected $table = 'icinga_endpoint'; + + protected $supportsImports = true; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'zone_id' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'host' => null, + 'port' => null, + 'log_duration' => null, + 'apiuser_id' => null, + ]; + + protected $relations = [ + 'zone' => 'IcingaZone', + 'apiuser' => 'IcingaApiUser', + ]; + + public function hasApiUser() + { + return $this->getResolvedProperty('apiuser_id') !== null; + } + + public function getApiUser() + { + $id = $this->getResolvedProperty('apiuser_id'); + if ($id === null) { + throw new RuntimeException('Trying to get API User for Endpoint without such: ' . $this->getObjectName()); + } + + return $this->getRelatedObject('apiuser', $id); + } + + /** + * Return a core API, depending on the configuration format + * + * @return CoreApi|LegacyDeploymentApi + */ + public function api() + { + $format = $this->connection->settings()->config_format; + if ($format === 'v2') { + $api = new CoreApi($this->getRestApiClient()); + $api->setDb($this->getConnection()); + + return $api; + } elseif ($format === 'v1') { + return new LegacyDeploymentApi($this->connection); + } else { + throw new InvalidArgumentException("Unsupported config format: $format"); + } + } + + /** + * @return RestApiClient + */ + public function getRestApiClient() + { + $client = new RestApiClient( + $this->getResolvedProperty('host', $this->getObjectName()), + $this->getResolvedProperty('port') + ); + + $user = $this->getApiUser(); + $client->setCredentials( + // TODO: $user->client_dn, + $user->object_name, + $user->password + ); + + return $client; + } + + public function getRenderingZone(IcingaConfig $config = null) + { + try { + if ($zone = $this->getResolvedRelated('zone')) { + return $zone->getRenderingZone($config); + } + } catch (NestingError $e) { + return self::RESOLVE_ERROR; + } + + return parent::getRenderingZone($config); + } + + /** + * @return int + */ + public function getResolvedPort() + { + $port = $this->getSingleResolvedProperty('port'); + if (null === $port) { + return 5665; + } else { + return (int) $port; + } + } + + public function getDescriptiveUrl() + { + return sprintf( + 'https://%s@%s:%d/v1/', + $this->getApiUser()->getObjectName(), + $this->getResolvedProperty('host', $this->getObjectName()), + $this->getResolvedPort() + ); + } + + /** + * Use duration time renderer helper + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderLog_duration() + { + // @codingStandardsIgnoreEnd + return $this->renderPropertyAsSeconds('log_duration'); + } + + /** + * Internal property, will not be rendered + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderApiuser_id() + { + // @codingStandardsIgnoreEnd + return ''; + } +} diff --git a/library/Director/Objects/IcingaFlatVar.php b/library/Director/Objects/IcingaFlatVar.php new file mode 100644 index 0000000..3bbf81c --- /dev/null +++ b/library/Director/Objects/IcingaFlatVar.php @@ -0,0 +1,61 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\CustomVariable\CustomVariable; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; + +class IcingaFlatVar extends DbObject +{ + protected $table = 'icinga_flat_var'; + + protected $keyName = [ + 'var_checksum', + 'flatname_checksum' + ]; + + protected $defaultProperties = [ + 'var_checksum' => null, + 'flatname_checksum' => null, + 'flatname' => null, + 'flatvalue' => null, + ]; + + protected $binaryProperties = [ + 'var_checksum', + 'flatname_checksum', + ]; + + public static function generateForCustomVar(CustomVariable $var, Db $db) + { + $flatVars = static::forCustomVar($var, $db); + foreach ($flatVars as $flat) { + $flat->store(); + } + + return $flatVars; + } + + public static function forCustomVar(CustomVariable $var, Db $db) + { + $flat = []; + $varSum = $var->checksum(); + $var->flatten($flat, $var->getKey()); + $flatVars = []; + + foreach ($flat as $name => $value) { + $flatVar = static::create([ + 'var_checksum' => $varSum, + 'flatname_checksum' => sha1($name, true), + 'flatname' => $name, + 'flatvalue' => $value, + ], $db); + + $flatVar->store(); + $flatVars[] = $flatVar; + } + + return $flatVars; + } +} diff --git a/library/Director/Objects/IcingaHost.php b/library/Director/Objects/IcingaHost.php new file mode 100644 index 0000000..2731f4a --- /dev/null +++ b/library/Director/Objects/IcingaHost.php @@ -0,0 +1,668 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Data\Db\DbConnection; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Data\PropertiesFilter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\Objects\Extension\FlappingSupport; +use InvalidArgumentException; +use RuntimeException; + +class IcingaHost extends IcingaObject implements ExportInterface +{ + use FlappingSupport; + + protected $table = 'icinga_host'; + + protected $defaultProperties = array( + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'display_name' => null, + 'address' => null, + 'address6' => null, + 'check_command_id' => null, + 'max_check_attempts' => null, + 'check_period_id' => null, + 'check_interval' => null, + 'retry_interval' => null, + 'check_timeout' => null, + 'enable_notifications' => null, + 'enable_active_checks' => null, + 'enable_passive_checks' => null, + 'enable_event_handler' => null, + 'enable_flapping' => null, + 'enable_perfdata' => null, + 'event_command_id' => null, + 'flapping_threshold_high' => null, + 'flapping_threshold_low' => null, + 'volatile' => null, + 'zone_id' => null, + 'command_endpoint_id' => null, + 'notes' => null, + 'notes_url' => null, + 'action_url' => null, + 'icon_image' => null, + 'icon_image_alt' => null, + 'has_agent' => null, + 'master_should_connect' => null, + 'accept_config' => null, + 'custom_endpoint_name' => null, + 'api_key' => null, + 'template_choice_id' => null, + ); + + protected $relations = array( + 'check_command' => 'IcingaCommand', + 'event_command' => 'IcingaCommand', + 'check_period' => 'IcingaTimePeriod', + 'command_endpoint' => 'IcingaEndpoint', + 'zone' => 'IcingaZone', + 'template_choice' => 'IcingaTemplateChoiceHost', + ); + + protected $booleans = array( + 'enable_notifications' => 'enable_notifications', + 'enable_active_checks' => 'enable_active_checks', + 'enable_passive_checks' => 'enable_passive_checks', + 'enable_event_handler' => 'enable_event_handler', + 'enable_flapping' => 'enable_flapping', + 'enable_perfdata' => 'enable_perfdata', + 'volatile' => 'volatile', + 'has_agent' => 'has_agent', + 'master_should_connect' => 'master_should_connect', + 'accept_config' => 'accept_config', + ); + + protected $intervalProperties = array( + 'check_interval' => 'check_interval', + 'check_timeout' => 'check_timeout', + 'retry_interval' => 'retry_interval', + ); + + protected $supportsCustomVars = true; + + protected $supportsGroups = true; + + protected $supportsImports = true; + + protected $supportsFields = true; + + protected $supportsChoices = true; + + protected $supportedInLegacy = true; + + /** @var HostGroupMembershipResolver */ + protected $hostgroupMembershipResolver; + + protected $uuidColumn = 'uuid'; + + public static function enumProperties( + DbConnection $connection = null, + $prefix = '', + $filter = null + ) { + $hostProperties = array(); + if ($filter === null) { + $filter = new PropertiesFilter(); + } + $realProperties = array_merge(['templates'], static::create()->listProperties()); + sort($realProperties); + + if ($filter->match(PropertiesFilter::$HOST_PROPERTY, 'name')) { + $hostProperties[$prefix . 'name'] = 'name'; + } + foreach ($realProperties as $prop) { + if (!$filter->match(PropertiesFilter::$HOST_PROPERTY, $prop)) { + continue; + } + + if (substr($prop, -3) === '_id') { + if ($prop === 'template_choice_id') { + continue; + } + $prop = substr($prop, 0, -3); + } + + $hostProperties[$prefix . $prop] = $prop; + } + unset($hostProperties[$prefix . 'uuid']); + unset($hostProperties[$prefix . 'custom_endpoint_name']); + + $hostVars = array(); + + if ($connection instanceof Db) { + foreach ($connection->fetchDistinctHostVars() as $var) { + if ($filter->match(PropertiesFilter::$CUSTOM_PROPERTY, $var->varname, $var)) { + if ($var->datatype) { + $hostVars[$prefix . 'vars.' . $var->varname] = sprintf( + '%s (%s)', + $var->varname, + $var->caption + ); + } else { + $hostVars[$prefix . 'vars.' . $var->varname] = $var->varname; + } + } + } + } + + //$properties['vars.*'] = 'Other custom variable'; + ksort($hostVars); + + + $props = mt('director', 'Host properties'); + $vars = mt('director', 'Custom variables'); + + $properties = array(); + if (!empty($hostProperties)) { + $properties[$props] = $hostProperties; + $properties[$props][$prefix . 'groups'] = 'Groups'; + } + + if (!empty($hostVars)) { + $properties[$vars] = $hostVars; + } + + return $properties; + } + + public function getCheckCommand() + { + $id = $this->getSingleResolvedProperty('check_command_id'); + return IcingaCommand::loadWithAutoIncId( + $id, + $this->getConnection() + ); + } + + public function hasCheckCommand() + { + return $this->getSingleResolvedProperty('check_command_id') !== null; + } + + public function renderToConfig(IcingaConfig $config) + { + parent::renderToConfig($config); + + // TODO: We might alternatively let the whole config fail in case we have + // used use_agent together with a legacy config + if (! $config->isLegacy()) { + $this->renderAgentZoneAndEndpoint($config); + } + } + + public function renderAgentZoneAndEndpoint(IcingaConfig $config = null) + { + if (!$this->isObject()) { + return; + } + + if ($this->isDisabled()) { + return; + } + + if ($this->getRenderingZone($config) === self::RESOLVE_ERROR) { + return; + } + + if ($this->getSingleResolvedProperty('has_agent') !== 'y') { + return; + } + + $name = $this->getEndpointName(); + + if (IcingaEndpoint::exists($name, $this->connection)) { + return; + } + + $props = array( + 'object_name' => $name, + 'object_type' => 'object', + 'log_duration' => 0 + ); + + if ($this->getSingleResolvedProperty('master_should_connect') === 'y') { + $props['host'] = $this->getSingleResolvedProperty('address'); + } + + $props['zone_id'] = $this->getSingleResolvedProperty('zone_id'); + + $endpoint = IcingaEndpoint::create($props, $this->connection); + + $zone = IcingaZone::create(array( + 'object_name' => $name, + ), $this->connection)->setEndpointList(array($name)); + + if ($props['zone_id']) { + $zone->parent_id = $props['zone_id']; + } else { + $zone->parent = $this->connection->getMasterZoneName(); + } + + $pre = 'zones.d/' . $this->getRenderingZone($config) . '/'; + $config->configFile($pre . 'agent_endpoints')->addObject($endpoint); + $config->configFile($pre . 'agent_zones')->addObject($zone); + } + + /** + // @codingStandardsIgnoreStart + * @param $value + * @return string + */ + protected function renderCustom_endpoint_name($value) + { + // @codingStandardsIgnoreEnd + // When feature flag feature_custom_endpoint is enabled, render custom var + if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') { + return c::renderKeyValue('vars._director_custom_endpoint_name', c::renderString($value)); + } + + return ''; + } + + /** + * Returns the hostname or custom endpoint name of the Icinga agent + * + * @return string + */ + public function getEndpointName() + { + $name = $this->getObjectName(); + + if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') { + if (($customName = $this->get('custom_endpoint_name')) !== null) { + $name = $customName; + } + } + + return $name; + } + + public function getAgentListenPort() + { + $conn = $this->connection; + $name = $this->getObjectName(); + if (IcingaEndpoint::exists($name, $conn)) { + return IcingaEndpoint::load($name, $conn)->getResolvedPort(); + } else { + return 5665; + } + } + + public function getUniqueIdentifier() + { + if ($this->isTemplate()) { + return $this->getObjectName(); + } else { + throw new RuntimeException( + 'getUniqueIdentifier() is supported by Host Templates only' + ); + } + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + // TODO: ksort in toPlainObject? + $props = (array) $this->toPlainObject(); + $props['fields'] = $this->loadFieldReferences(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaHost + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + if ($properties['object_type'] !== 'template') { + throw new InvalidArgumentException(sprintf( + 'Can import only Templates, got "%s" for "%s"', + $properties['object_type'], + $name + )); + } + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Service Template "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + // $object->newFields = $properties['fields']; + unset($properties['fields']); + $object->setProperties($properties); + + return $object; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return array + */ + protected function loadFieldReferences() + { + $db = $this->getDb(); + + $res = $db->fetchAll( + $db->select()->from([ + 'hf' => 'icinga_host_field' + ], [ + 'hf.datafield_id', + 'hf.is_required', + 'hf.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = hf.datafield_id', []) + ->where('host_id = ?', $this->get('id')) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } else { + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + return $res; + } + } + + public function beforeDelete() + { + foreach ($this->fetchServices() as $service) { + $service->delete(); + } + foreach ($this->fetchServiceSets() as $set) { + $set->delete(); + } + + parent::beforeDelete(); + } + + public function hasAnyOverridenServiceVars() + { + $varname = $this->getServiceOverrivesVarname(); + return isset($this->vars()->$varname); + } + + public function getAllOverriddenServiceVars() + { + if ($this->hasAnyOverridenServiceVars()) { + $varname = $this->getServiceOverrivesVarname(); + return $this->vars()->$varname->getValue(); + } else { + return (object) array(); + } + } + + public function hasOverriddenServiceVars($service) + { + $all = $this->getAllOverriddenServiceVars(); + return property_exists($all, $service); + } + + public function getOverriddenServiceVars($service) + { + if ($this->hasOverriddenServiceVars($service)) { + $all = $this->getAllOverriddenServiceVars(); + return $all->$service; + } else { + return (object) array(); + } + } + + public function overrideServiceVars($service, $vars) + { + // For PHP < 5.5.0: + $array = (array) $vars; + if (empty($array)) { + return $this->unsetOverriddenServiceVars($service); + } + + $all = $this->getAllOverriddenServiceVars(); + $all->$service = $vars; + $varname = $this->getServiceOverrivesVarname(); + $this->vars()->$varname = $all; + + return $this; + } + + public function unsetOverriddenServiceVars($service) + { + if ($this->hasOverriddenServiceVars($service)) { + $all = (array) $this->getAllOverriddenServiceVars(); + unset($all[$service]); + + $varname = $this->getServiceOverrivesVarname(); + if (empty($all)) { + unset($this->vars()->$varname); + } else { + $this->vars()->$varname = (object) $all; + } + } + + return $this; + } + + protected function notifyResolvers() + { + $resolver = $this->getHostGroupMembershipResolver(); + $resolver->addObject($this); + $resolver->refreshDb(); + + return $this; + } + + protected function getHostGroupMembershipResolver() + { + if ($this->hostgroupMembershipResolver === null) { + $this->hostgroupMembershipResolver = new HostGroupMembershipResolver( + $this->getConnection() + ); + } + + return $this->hostgroupMembershipResolver; + } + + public function setHostGroupMembershipResolver(HostGroupMembershipResolver $resolver) + { + $this->hostgroupMembershipResolver = $resolver; + return $this; + } + + protected function getServiceOverrivesVarname() + { + return $this->connection->settings()->override_services_varname; + } + + /** + * Internal property, will not be rendered + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderHas_Agent() + { + return ''; + } + + /** + * Internal property, will not be rendered + * + * @return string + */ + protected function renderMaster_should_connect() + { + return ''; + } + + /** + * Internal property, will not be rendered + * + * @return string + */ + protected function renderApi_key() + { + return ''; + } + + /** + * Internal property, will not be rendered + * + * @return string + */ + protected function renderTemplate_choice_id() + { + return ''; + } + + /** + * Internal property, will not be rendered + * + * @return string + */ + protected function renderAccept_config() + { + // @codingStandardsIgnoreEnd + return ''; + } + + /** + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderLegacyDisplay_Name() + { + // @codingStandardsIgnoreEnd + return c1::renderKeyValue('display_name', $this->display_name); + } + + protected function renderLegacyVolatile() + { + // not available for hosts in Icinga 1.x + return; + } + + protected function renderLegacyCustomExtensions() + { + $str = parent::renderLegacyCustomExtensions(); + + if (($alias = $this->vars()->get('alias')) !== null) { + $str .= c1::renderKeyValue('alias', $alias->getValue()); + } + + return $str; + } + + /** + * @return IcingaService[] + */ + public function fetchServices() + { + $connection = $this->getConnection(); + $db = $connection->getDbAdapter(); + + /** @var IcingaService[] $services */ + $services = IcingaService::loadAll( + $connection, + $db->select()->from('icinga_service') + ->where('host_id = ?', $this->get('id')) + ); + + return $services; + } + + /** + * @return IcingaServiceSet[] + */ + public function fetchServiceSets() + { + $connection = $this->getConnection(); + $db = $connection->getDbAdapter(); + + /** @var IcingaServiceSet[] $sets */ + $sets = IcingaServiceSet::loadAll( + $connection, + $db->select()->from('icinga_service_set') + ->where('host_id = ?', $this->get('id')) + ); + + return $sets; + } + + /** + * @return string + */ + public function generateApiKey() + { + $key = sha1( + (string) microtime(false) + . $this->getObjectName() + . rand(1, 1000000) + ); + + if ($this->dbHasApiKey($key)) { + $key = $this->generateApiKey(); + } + + $this->set('api_key', $key); + + return $key; + } + + protected function dbHasApiKey($key) + { + $db = $this->getDb(); + $query = $db->select()->from( + ['o' => $this->getTableName()], + 'o.api_key' + )->where('api_key = ?', $key); + + return $db->fetchOne($query) === $key; + } + + public static function loadWithApiKey($key, Db $db) + { + $query = $db->getDbAdapter() + ->select() + ->from('icinga_host') + ->where('api_key = ?', $key); + + $result = self::loadAll($db, $query); + if (count($result) !== 1) { + throw new NotFoundError('Got invalid API key "%s"', $key); + } + + return current($result); + } +} diff --git a/library/Director/Objects/IcingaHostField.php b/library/Director/Objects/IcingaHostField.php new file mode 100644 index 0000000..b68c9d4 --- /dev/null +++ b/library/Director/Objects/IcingaHostField.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaHostField extends IcingaObjectField +{ + protected $keyName = array('host_id', 'datafield_id'); + + protected $table = 'icinga_host_field'; + + protected $defaultProperties = array( + 'host_id' => null, + 'datafield_id' => null, + 'is_required' => null, + 'var_filter' => null, + ); +} diff --git a/library/Director/Objects/IcingaHostGroup.php b/library/Director/Objects/IcingaHostGroup.php new file mode 100644 index 0000000..e11f672 --- /dev/null +++ b/library/Director/Objects/IcingaHostGroup.php @@ -0,0 +1,42 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaHostGroup extends IcingaObjectGroup +{ + protected $table = 'icinga_hostgroup'; + + /** @var HostGroupMembershipResolver */ + protected $hostgroupMembershipResolver; + + public function supportsAssignments() + { + return true; + } + + protected function getHostGroupMembershipResolver() + { + if ($this->hostgroupMembershipResolver === null) { + $this->hostgroupMembershipResolver = new HostGroupMembershipResolver( + $this->getConnection() + ); + } + + return $this->hostgroupMembershipResolver; + } + + public function setHostGroupMembershipResolver(HostGroupMembershipResolver $resolver) + { + $this->hostgroupMembershipResolver = $resolver; + return $this; + } + + protected function notifyResolvers() + { + $resolver = $this->getHostGroupMembershipResolver(); + $resolver->addGroup($this); + $resolver->refreshDb(); + + return $this; + } +} diff --git a/library/Director/Objects/IcingaHostGroupAssignment.php b/library/Director/Objects/IcingaHostGroupAssignment.php new file mode 100644 index 0000000..4e0e5a2 --- /dev/null +++ b/library/Director/Objects/IcingaHostGroupAssignment.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaHostGroupAssignment extends IcingaObject +{ + protected $table = 'icinga_hostgroup_assignment'; + + protected $keyName = 'id'; + + protected $defaultProperties = array( + 'id' => null, + 'service_id' => null, + 'filter_string' => null, + ); + + protected $relations = array( + 'service' => 'IcingaHostGroup', + ); +} diff --git a/library/Director/Objects/IcingaHostVar.php b/library/Director/Objects/IcingaHostVar.php new file mode 100644 index 0000000..45656d5 --- /dev/null +++ b/library/Director/Objects/IcingaHostVar.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaHostVar extends IcingaObject +{ + protected $keyName = array('host_id', 'varname'); + + protected $table = 'icinga_host_var'; + + protected $defaultProperties = array( + 'host_id' => null, + 'varname' => null, + 'varvalue' => null, + 'format' => null, + ); + + public function onInsert() + { + } + + public function onUpdate() + { + } + + public function onDelete() + { + } +} diff --git a/library/Director/Objects/IcingaNotification.php b/library/Director/Objects/IcingaNotification.php new file mode 100644 index 0000000..9c5d08d --- /dev/null +++ b/library/Director/Objects/IcingaNotification.php @@ -0,0 +1,254 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use RuntimeException; + +class IcingaNotification extends IcingaObject implements ExportInterface +{ + protected $table = 'icinga_notification'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'apply_to' => null, + 'host_id' => null, + 'service_id' => null, + // 'users' => null, + // 'user_groups' => null, + 'times_begin' => null, + 'times_end' => null, + 'command_id' => null, + 'notification_interval' => null, + 'period_id' => null, + 'zone_id' => null, + 'assign_filter' => null, + ]; + + protected $uuidColumn = 'uuid'; + + protected $supportsCustomVars = true; + + protected $supportsFields = true; + + protected $supportsImports = true; + + protected $supportsApplyRules = true; + + protected $relatedSets = [ + 'states' => 'StateFilterSet', + 'types' => 'TypeFilterSet', + ]; + + protected $multiRelations = [ + 'users' => 'IcingaUser', + 'user_groups' => 'IcingaUserGroup', + ]; + + protected $relations = [ + 'zone' => 'IcingaZone', + 'host' => 'IcingaHost', + 'service' => 'IcingaService', + 'command' => 'IcingaCommand', + 'period' => 'IcingaTimePeriod', + ]; + + protected $intervalProperties = [ + 'notification_interval' => 'interval', + 'times_begin' => 'times_begin', + 'times_end' => 'times_end', + ]; + + protected function prefersGlobalZone() + { + return false; + } + + /** + * @codingStandardsIgnoreStart + * @return string + */ + protected function renderTimes_begin() + { + // @codingStandardsIgnoreEnd + return c::renderKeyValue('times.begin', c::renderInterval($this->get('times_begin'))); + } + + /** + * @codingStandardsIgnoreStart + * @return string + */ + protected function renderTimes_end() + { + // @codingStandardsIgnoreEnd + return c::renderKeyValue('times.end', c::renderInterval($this->get('times_end'))); + } + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @return \stdClass + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + // TODO: ksort in toPlainObject? + $props = (array) $this->toPlainObject(); + $props['fields'] = $this->loadFieldReferences(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Notification "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + // $object->newFields = $properties['fields']; + unset($properties['fields']); + $object->setProperties($properties); + + return $object; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return array + */ + protected function loadFieldReferences() + { + $db = $this->getDb(); + + $res = $db->fetchAll( + $db->select()->from([ + 'nf' => 'icinga_notification_field' + ], [ + 'nf.datafield_id', + 'nf.is_required', + 'nf.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = nf.datafield_id', []) + ->where('notification_id = ?', $this->get('id')) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } else { + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + return $res; + } + } + + /** + * Do not render internal property apply_to + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderApply_to() + { + // @codingStandardsIgnoreEnd + return ''; + } + + protected function renderObjectHeader() + { + if ($this->isApplyRule()) { + if (($to = $this->get('apply_to')) === null) { + throw new RuntimeException(sprintf( + 'No "apply_to" object type has been set for Applied notification "%s"', + $this->getObjectName() + )); + } + + return sprintf( + "%s %s %s to %s {\n", + $this->getObjectTypeName(), + $this->getType(), + c::renderString($this->getObjectName()), + ucfirst($to) + ); + } else { + return parent::renderObjectHeader(); + } + } + + /** + * Render host_id as host_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderHost_id() + { + // @codingStandardsIgnoreEnd + return $this->renderRelationProperty('host', $this->get('host_id'), 'host_name'); + } + + /** + * Render service_id as service_name + * + * @codingStandardsIgnoreStart + * @return string + */ + public function renderService_id() + { + // @codingStandardsIgnoreEnd + return $this->renderRelationProperty('service', $this->get('service_id'), 'service_name'); + } + + protected function setKey($key) + { + if (is_int($key)) { + $this->id = $key; + } elseif (is_array($key)) { + foreach (['id', 'host_id', 'service_id', 'object_name'] as $k) { + if (array_key_exists($k, $key)) { + $this->set($k, $key[$k]); + } + } + } else { + return parent::setKey($key); + } + + return $this; + } +} diff --git a/library/Director/Objects/IcingaNotificationField.php b/library/Director/Objects/IcingaNotificationField.php new file mode 100644 index 0000000..d51f9e6 --- /dev/null +++ b/library/Director/Objects/IcingaNotificationField.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaNotificationField extends IcingaObjectField +{ + protected $keyName = array('notification_id', 'datafield_id'); + + protected $table = 'icinga_notification_field'; + + protected $defaultProperties = array( + 'notification_id' => null, + 'datafield_id' => null, + 'is_required' => null, + 'var_filter' => null, + ); +} diff --git a/library/Director/Objects/IcingaObject.php b/library/Director/Objects/IcingaObject.php new file mode 100644 index 0000000..04ae32b --- /dev/null +++ b/library/Director/Objects/IcingaObject.php @@ -0,0 +1,3258 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\CustomVariable\CustomVariables; +use Icinga\Module\Director\Data\Db\DbDataFormatter; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\IcingaConfig\AssignRenderer; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\IcingaConfig\ExtensibleSet; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use LogicException; +use RuntimeException; + +abstract class IcingaObject extends DbObject implements IcingaConfigRenderer +{ + const RESOLVE_ERROR = '(unable to resolve)'; + + protected $keyName = 'object_name'; + + protected $autoincKeyName = 'id'; + + /** @var bool Whether this Object supports custom variables */ + protected $supportsCustomVars = false; + + /** @var bool Whether there exist Groups for this object type */ + protected $supportsGroups = false; + + /** @var bool Whether this Object makes use of (time) ranges */ + protected $supportsRanges = false; + + /** @var bool Whether inheritance via "imports" property is supported */ + protected $supportsImports = false; + + /** @var bool Allows controlled custom var access through Fields */ + protected $supportsFields = false; + + /** @var bool Whether this object can be rendered as 'apply Object' */ + protected $supportsApplyRules = false; + + /** @var bool Whether Sets of object can be defined */ + protected $supportsSets = false; + + /** @var bool Whether this Object supports template-based Choices */ + protected $supportsChoices = false; + + /** @var bool If the object is rendered in legacy config */ + protected $supportedInLegacy = false; + + protected $rangeClass; + + protected $type; + + /* key/value!! */ + protected $booleans = []; + + // Property suffixed with _id must exist + protected $relations = [ + // property => PropertyClass + ]; + + protected $relatedSets = [ + // property => ExtensibleSetClass + ]; + + protected $multiRelations = [ + // property => IcingaObjectClass + ]; + + /** @var IcingaObjectMultiRelations[] */ + protected $loadedMultiRelations = []; + + /** + * Allows to set properties pointing to related objects by name without + * loading the related object. + * + * @var array + */ + protected $unresolvedRelatedProperties = []; + + protected $loadedRelatedSets = []; + + // Will be rendered first, before imports + protected $prioritizedProperties = []; + + protected $propertiesNotForRendering = [ + 'id', + 'object_name', + 'object_type', + ]; + + /** + * Array of interval property names + * + * Those will be automagically munged to integers (seconds) and rendered + * as durations (e.g. 2m 10s). Array expects (propertyName => renderedKey) + * + * @var array + */ + protected $intervalProperties = []; + + /** @var Db */ + protected $connection; + + private $vars; + + /** @var IcingaObjectGroups */ + private $groups; + + private $imports; + + /** @var IcingaTimePeriodRanges - TODO: generic ranges */ + private $ranges; + + private $shouldBeRemoved = false; + + private $resolveCache = []; + + private $cachedPlainUnmodified; + + private $templateResolver; + + protected static $tree; + + /** + * @return Db + */ + public function getConnection() + { + return $this->connection; + } + + public function propertyIsBoolean($property) + { + return array_key_exists($property, $this->booleans); + } + + public function propertyIsInterval($property) + { + return array_key_exists($property, $this->intervalProperties); + } + + /** + * Whether a property ends with _id and might refer another object + * + * @param $property string Property name, like zone_id + * + * @return bool + */ + public function propertyIsRelation($property) + { + if ($key = $this->stripIdSuffix($property)) { + return $this->hasRelation($key); + } + + return false; + } + + protected function stripIdSuffix($key) + { + $end = substr($key, -3); + + if ('_id' === $end) { + return substr($key, 0, -3); + } + + return false; + } + + public function propertyIsRelatedSet($property) + { + return array_key_exists($property, $this->relatedSets); + } + + public function propertyIsMultiRelation($property) + { + return array_key_exists($property, $this->multiRelations); + } + + public function listMultiRelations() + { + return array_keys($this->multiRelations); + } + + public function getMultiRelation($property) + { + if (! $this->hasLoadedMultiRelation($property)) { + $this->loadMultiRelation($property); + } + + return $this->loadedMultiRelations[$property]; + } + + public function setMultiRelation($property, $values) + { + $this->getMultiRelation($property)->set($values); + return $this; + } + + private function loadMultiRelation($property) + { + if ($this->hasBeenLoadedFromDb()) { + $rel = IcingaObjectMultiRelations::loadForStoredObject( + $this, + $property, + $this->multiRelations[$property] + ); + } else { + $rel = new IcingaObjectMultiRelations( + $this, + $property, + $this->multiRelations[$property] + ); + } + + $this->loadedMultiRelations[$property] = $rel; + } + + private function hasLoadedMultiRelation($property) + { + return array_key_exists($property, $this->loadedMultiRelations); + } + + private function loadAllMultiRelations() + { + foreach (array_keys($this->multiRelations) as $key) { + if (! $this->hasLoadedMultiRelation($key)) { + $this->loadMultiRelation($key); + } + } + + ksort($this->loadedMultiRelations); + return $this->loadedMultiRelations; + } + + protected function getRelatedSetClass($property) + { + $prefix = '\\Icinga\\Module\\Director\\IcingaConfig\\'; + return $prefix . $this->relatedSets[$property]; + } + + /** + * @param $property + * @return ExtensibleSet + */ + protected function getRelatedSet($property) + { + if (! array_key_exists($property, $this->loadedRelatedSets)) { + /** @var ExtensibleSet $class */ + $class = $this->getRelatedSetClass($property); + $this->loadedRelatedSets[$property] + = $class::forIcingaObject($this, $property); + } + + return $this->loadedRelatedSets[$property]; + } + + /** + * @return ExtensibleSet[] + */ + protected function relatedSets() + { + $sets = []; + foreach ($this->relatedSets as $key => $class) { + $sets[$key] = $this->getRelatedSet($key); + } + + return $sets; + } + + /** + * Whether the given property name is a short name for a relation + * + * This might be 'zone' for 'zone_id' + * + * @param string $property Property name + * + * @return bool + */ + public function hasRelation($property) + { + return array_key_exists($property, $this->relations); + } + + protected function getRelationClass($property) + { + return __NAMESPACE__ . '\\' . $this->relations[$property]; + } + + protected function getRelationObjectClass($property) + { + return $this->relations[$property]; + } + + /** + * @param $property + * @return IcingaObject + */ + public function getRelated($property) + { + return $this->getRelatedObject($property, $this->{$property . '_id'}); + } + + /** + * @param $property + * @param $id + * @return string + */ + public function getRelatedObjectName($property, $id) + { + return $this->getRelatedObject($property, $id)->getObjectName(); + } + + /** + * @param $property + * @param $id + * @return IcingaObject + */ + protected function getRelatedObject($property, $id) + { + /** @var IcingaObject $class */ + $class = $this->getRelationClass($property); + try { + $object = $class::loadWithAutoIncId($id, $this->connection); + } catch (NotFoundError $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return $object; + } + + /** + * @param $property + * @return IcingaObject|null + */ + public function getResolvedRelated($property) + { + $id = $this->getSingleResolvedProperty($property . '_id'); + + if ($id) { + return $this->getRelatedObject($property, $id); + } + + return null; + } + + public function prefetchAllRelatedTypes() + { + foreach (array_unique(array_values($this->relations)) as $relClass) { + /** @var static $class */ + $class = __NAMESPACE__ . '\\' . $relClass; + $class::prefetchAll($this->getConnection()); + } + } + + public static function prefetchAllRelationsByType($type, Db $db) + { + /** @var static $class */ + $class = DbObjectTypeRegistry::classByType($type); + /** @var static $dummy */ + $dummy = $class::create([], $db); + $dummy->prefetchAllRelatedTypes(); + } + + /** + * Whether this Object supports custom variables + * + * @return bool + */ + public function supportsCustomVars() + { + return $this->supportsCustomVars; + } + + /** + * Whether there exist Groups for this object type + * + * @return bool + */ + public function supportsGroups() + { + return $this->supportsGroups; + } + + /** + * Whether this Object makes use of (time) ranges + * + * @return bool + */ + public function supportsRanges() + { + return $this->supportsRanges; + } + + /** + * Whether this object supports (command) Arguments + * + * @return bool + */ + public function supportsArguments() + { + return $this instanceof ObjectWithArguments; + } + + /** + * Whether this object supports inheritance through the "imports" property + * + * @return bool + */ + public function supportsImports() + { + return $this->supportsImports; + } + + /** + * Whether this object allows controlled custom var access through fields + * + * @return bool + */ + public function supportsFields() + { + return $this->supportsFields; + } + + /** + * Whether this object can be rendered as 'apply Object' + * + * @return bool + */ + public function supportsApplyRules() + { + return $this->supportsApplyRules; + } + + /** + * Whether this object supports 'assign' properties + * + * @return bool + */ + public function supportsAssignments() + { + return $this->isApplyRule(); + } + + /** + * Whether this object can be part of a 'set' + * + * @return bool + */ + public function supportsSets() + { + return $this->supportsSets; + } + + /** + * Whether this object supports template-based Choices + * + * @return bool + */ + public function supportsChoices() + { + return $this->supportsChoices; + } + + public function setAssignments($value) + { + return IcingaObjectLegacyAssignments::applyToObject($this, $value); + } + + /** + * @codingStandardsIgnoreStart + * + * @param Filter|string $filter + * + * @throws LogicException + * + * @return self + */ + public function setAssign_filter($filter) + { + if (! $this->supportsAssignments() && $filter !== null) { + if ($this->hasProperty('object_type')) { + $type = $this->get('object_type'); + } else { + $type = get_class($this); + } + + if ($type === null) { + throw new LogicException( + 'Cannot set assign_filter unless object_type has been set' + ); + } + throw new LogicException(sprintf( + 'I can only assign for applied objects or objects with native' + . ' support for assignments, got %s', + $type + )); + } + + // @codingStandardsIgnoreEnd + if ($filter instanceof Filter) { + $filter = $filter->toQueryString(); + } + + return $this->reallySet('assign_filter', $filter); + } + + /** + * It sometimes makes sense to defer lookups for related properties. This + * kind of lazy-loading allows us to for example set host = 'localhost' and + * render an object even when no such host exists. Think of the activity log, + * one might want to visualize a history host or service template even when + * the related command has been deleted in the meantime. + * + * @return self + */ + public function resolveUnresolvedRelatedProperties() + { + foreach ($this->unresolvedRelatedProperties as $name => $p) { + $this->resolveUnresolvedRelatedProperty($name); + } + + return $this; + } + + public function getUnresolvedRelated($property) + { + if ($this->hasRelation($property)) { + $property .= '_id'; + if (isset($this->unresolvedRelatedProperties[$property])) { + return $this->unresolvedRelatedProperties[$property]; + } + + return null; + } + + throw new RuntimeException(sprintf( + '%s "%s" has no %s reference', + $this->getShortTableName(), + $this->getObjectName(), + $property + )); + } + + /** + * @param $name + */ + protected function resolveUnresolvedRelatedProperty($name) + { + $short = substr($name, 0, -3); + /** @var IcingaObject $class */ + $class = $this->getRelationClass($short); + try { + $object = $class::load( + $this->unresolvedRelatedProperties[$name], + $this->connection + ); + } catch (NotFoundError $e) { + // Hint: eventually a NotFoundError would be better + throw new RuntimeException(sprintf( + 'Unable to load object (%s: %s) referenced from %s "%s", %s', + $short, + $this->unresolvedRelatedProperties[$name], + $this->getShortTableName(), + $this->getObjectName(), + lcfirst($e->getMessage()) + ), $e->getCode(), $e); + } + + $id = $object->get('id'); + // Happens when load() get's a branched object, created in the branch + if ($id !== null) { + $this->reallySet($name, $id); + unset($this->unresolvedRelatedProperties[$name]); + } + } + + /** + * @return bool + */ + public function hasBeenModified() + { + if (parent::hasBeenModified()) { + return true; + } + + if ($this->hasUnresolvedRelatedProperties()) { + $this->resolveUnresolvedRelatedProperties(); + + // Duplicates above code, but this makes it faster: + if (parent::hasBeenModified()) { + return true; + } + } + + if ($this->supportsCustomVars() && $this->vars !== null && $this->vars()->hasBeenModified()) { + return true; + } + + if ($this->supportsGroups() && $this->groups !== null && $this->groups()->hasBeenModified()) { + return true; + } + + if ($this->supportsImports() && $this->imports !== null && $this->imports()->hasBeenModified()) { + return true; + } + + if ($this->supportsRanges() && $this->ranges !== null && $this->ranges()->hasBeenModified()) { + return true; + } + + if ($this instanceof ObjectWithArguments + && $this->gotArguments() + && $this->arguments()->hasBeenModified() + ) { + return true; + } + + foreach ($this->loadedRelatedSets as $set) { + if ($set->hasBeenModified()) { + return true; + } + } + + foreach ($this->loadedMultiRelations as $rel) { + if ($rel->hasBeenModified()) { + return true; + } + } + + return false; + } + + protected function hasUnresolvedRelatedProperties() + { + return ! empty($this->unresolvedRelatedProperties); + } + + protected function hasUnresolvedRelatedProperty($name) + { + return array_key_exists($name, $this->unresolvedRelatedProperties); + } + + /** + * @param $key + * @return mixed + */ + protected function getRelationId($key) + { + if ($this->hasUnresolvedRelatedProperty($key)) { + $this->resolveUnresolvedRelatedProperty($key); + } + + return parent::get($key); + } + + /** + * @param $key + * @return string|null + */ + protected function getRelatedProperty($key) + { + $idKey = $key . '_id'; + if ($this->hasUnresolvedRelatedProperty($idKey)) { + return $this->unresolvedRelatedProperties[$idKey]; + } + + if ($id = $this->get($idKey)) { + /** @var IcingaObject $class */ + $class = $this->getRelationClass($key); + try { + $object = $class::loadWithAutoIncId($id, $this->connection); + } catch (NotFoundError $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return $object->getObjectName(); + } + + return null; + } + + /** + * @param string $key + * @return \Icinga\Module\Director\CustomVariable\CustomVariable|mixed|null + */ + public function get($key) + { + if (substr($key, 0, 5) === 'vars.') { + $var = $this->vars()->get(substr($key, 5)); + if ($var === null) { + return $var; + } + + return $var->getValue(); + } + + // e.g. zone_id + if ($this->propertyIsRelation($key)) { + return $this->getRelationId($key); + } + + // e.g. zone + if ($this->hasRelation($key)) { + return $this->getRelatedProperty($key); + } + + if ($this->propertyIsRelatedSet($key)) { + return $this->getRelatedSet($key)->toPlainObject(); + } + + if ($this->propertyIsMultiRelation($key)) { + return $this->getMultiRelation($key)->listRelatedNames(); + } + + return parent::get($key); + } + + public function setProperties($props) + { + if (is_array($props)) { + if (array_key_exists('object_type', $props) && key($props) !== 'object_type') { + $type = $props['object_type']; + unset($props['object_type']); + $props = ['object_type' => $type] + $props; + } + } + return parent::setProperties($props); + } + + public function set($key, $value) + { + if ($key === 'vars') { + $value = (array) $value; + $unset = []; + foreach ($this->vars() as $k => $f) { + if (! array_key_exists($k, $value)) { + $unset[] = $k; + } + } + foreach ($unset as $k) { + unset($this->vars()->$k); + } + foreach ($value as $k => $v) { + $this->vars()->set($k, $v); + } + return $this; + } + + if (substr($key, 0, 5) === 'vars.') { + //TODO: allow for deep keys + $this->vars()->set(substr($key, 5), $value); + return $this; + } + + if ($this instanceof ObjectWithArguments + && substr($key, 0, 10) === 'arguments.') { + $this->arguments()->set(substr($key, 10), $value); + return $this; + } + + if ($this->propertyIsBoolean($key)) { + return parent::set($key, DbDataFormatter::normalizeBoolean($value)); + } + + // e.g. zone_id + if ($this->propertyIsRelation($key)) { + return $this->setRelation($key, $value); + } + + // e.g. zone + if ($this->hasRelation($key)) { + return $this->setUnresolvedRelation($key, $value); + } + + if ($this->propertyIsMultiRelation($key)) { + $this->setMultiRelation($key, $value); + return $this; + } + + if ($this->propertyIsRelatedSet($key)) { + $this->getRelatedSet($key)->set($value); + return $this; + } + + if ($this->propertyIsInterval($key)) { + return parent::set($key, c::parseInterval($value)); + } + + return parent::set($key, $value); + } + + private function setRelation($key, $value) + { + if ((int) $key !== (int) $this->$key) { + unset($this->unresolvedRelatedProperties[$key]); + } + return parent::set($key, $value); + } + + private function setUnresolvedRelation($key, $value) + { + if ($value === null || strlen($value) === 0) { + unset($this->unresolvedRelatedProperties[$key . '_id']); + return parent::set($key . '_id', null); + } + + $this->unresolvedRelatedProperties[$key . '_id'] = $value; + return $this; + } + + protected function setRanges($ranges) + { + $this->ranges()->set((array) $ranges); + return $this; + } + + protected function getRanges() + { + return $this->ranges()->getValues(); + } + + protected function setDisabled($disabled) + { + return $this->reallySet('disabled', DbDataFormatter::normalizeBoolean($disabled)); + } + + public function isDisabled() + { + return $this->get('disabled') === 'y'; + } + + public function markForRemoval($remove = true) + { + $this->shouldBeRemoved = $remove; + return $this; + } + + public function shouldBeRemoved() + { + return $this->shouldBeRemoved; + } + + public function shouldBeRenamed() + { + return $this->hasBeenLoadedFromDb() + && $this->getOriginalProperty('object_name') !== $this->getObjectName(); + } + + /** + * @return IcingaObjectGroups + */ + public function groups() + { + $this->assertGroupsSupport(); + if ($this->groups === null) { + if ($this->hasBeenLoadedFromDb() && $this->get('id')) { + $this->groups = IcingaObjectGroups::loadForStoredObject($this); + } else { + $this->groups = new IcingaObjectGroups($this); + } + } + + return $this->groups; + } + + public function hasModifiedGroups() + { + $this->assertGroupsSupport(); + if ($this->groups === null) { + return false; + } + + return $this->groups->hasBeenModified(); + } + + public function getAppliedGroups() + { + $this->assertGroupsSupport(); + if (! $this instanceof IcingaHost) { + throw new RuntimeException('getAppliedGroups is only available for hosts currently!'); + } + if (! $this->hasBeenLoadedFromDb()) { + // There are no stored related/resolved groups. We'll also not resolve + // them here on demand. + return []; + } + $id = $this->get('id'); + if ($id === null) { + // Do not fail for branches. Should be handled otherwise + // TODO: throw an Exception, once we are able to deal with this + return []; + } + + $type = strtolower($this->getType()); + $query = $this->db->select()->from( + ['gr' => "icinga_${type}group_${type}_resolved"], + ['g.object_name'] + )->join( + ['g' => "icinga_${type}group"], + "g.id = gr.${type}group_id", + [] + )->joinLeft( + ['go' => "icinga_${type}group_${type}"], + "go.${type}group_id = gr.${type}group_id AND go.${type}_id = " . (int) $id, + [] + )->where( + "gr.${type}_id = ?", + (int) $id + )->where("go.${type}_id IS NULL")->order('g.object_name'); + + return $this->db->fetchCol($query); + } + + /** + * @return IcingaTimePeriodRanges + */ + public function ranges() + { + $this->assertRangesSupport(); + if ($this->ranges === null) { + /** @var IcingaTimePeriodRanges $class */ + $class = $this->getRangeClass(); + if ($this->hasBeenLoadedFromDb()) { + $this->ranges = $class::loadForStoredObject($this); + } else { + $this->ranges = new $class($this); + } + } + + return $this->ranges; + } + + protected function getRangeClass() + { + if ($this->rangeClass === null) { + $this->rangeClass = get_class($this) . 'Ranges'; + } + + return $this->rangeClass; + } + + /** + * @return IcingaObjectImports + */ + public function imports() + { + $this->assertImportsSupport(); + if ($this->imports === null) { + // can not use hasBeenLoadedFromDb() when in onStore() + if ($this->getProperty('id') !== null) { + $this->imports = IcingaObjectImports::loadForStoredObject($this); + } else { + $this->imports = new IcingaObjectImports($this); + } + } + + return $this->imports; + } + + public function gotImports() + { + return $this->imports !== null; + } + + public function setImports($imports) + { + if (! is_array($imports) && $imports !== null) { + $imports = [$imports]; + } + + try { + $this->imports()->set($imports); + } catch (NestingError $e) { + $this->imports = new IcingaObjectImports($this); + // Force modification, otherwise it won't be stored when empty + $this->imports->setModified()->set($imports); + } + + if ($this->imports()->hasBeenModified()) { + $this->invalidateResolveCache(); + } + } + + public function getImports() + { + return $this->listImportNames(); + } + + /** + * @deprecated This should no longer be in use + * @return IcingaTemplateResolver + */ + public function templateResolver() + { + if ($this->templateResolver === null) { + $this->templateResolver = new IcingaTemplateResolver($this); + } + + return $this->templateResolver; + } + + public function getResolvedProperty($key, $default = null) + { + if (array_key_exists($key, $this->unresolvedRelatedProperties)) { + $this->resolveUnresolvedRelatedProperty($key); + $this->invalidateResolveCache(); + } + + $properties = $this->getResolvedProperties(); + if (property_exists($properties, $key)) { + return $properties->$key; + } + + return $default; + } + + public function getInheritedProperty($key, $default = null) + { + if (array_key_exists($key, $this->unresolvedRelatedProperties)) { + $this->resolveUnresolvedRelatedProperty($key); + $this->invalidateResolveCache(); + } + + $properties = $this->getInheritedProperties(); + if (property_exists($properties, $key)) { + return $properties->$key; + } + + return $default; + } + + public function getInheritedVar($varname) + { + try { + $vars = $this->getInheritedVars(); + } catch (NestingError $e) { + return null; + } + + if (property_exists($vars, $varname)) { + return $vars->$varname; + } + + return null; + } + + public function getResolvedVar($varName) + { + try { + $vars = $this->getResolvedVars(); + } catch (NestingError $e) { + return null; + } + + if (property_exists($vars, $varName)) { + return $vars->$varName; + } + + return null; + } + + public function getOriginForVar($varName) + { + try { + $origins = $this->getOriginsVars(); + } catch (NestingError $e) { + return null; + } + + if (property_exists($origins, $varName)) { + return $origins->$varName; + } + + return null; + } + + public function getResolvedProperties() + { + return $this->getResolved('Properties'); + } + + public function getInheritedProperties() + { + return $this->getInherited('Properties'); + } + + public function getOriginsProperties() + { + return $this->getOrigins('Properties'); + } + + public function resolveProperties() + { + return $this->resolve('Properties'); + } + + public function getResolvedVars() + { + return $this->getResolved('Vars'); + } + + public function getInheritedVars() + { + return $this->getInherited('Vars'); + } + + public function resolveVars() + { + return $this->resolve('Vars'); + } + + public function getOriginsVars() + { + return $this->getOrigins('Vars'); + } + + public function getVars() + { + $vars = []; + foreach ($this->vars() as $key => $var) { + if ($var->hasBeenDeleted()) { + continue; + } + + $vars[$key] = $var->getValue(); + } + ksort($vars); + + return (object) $vars; + } + + /** + * This is mostly for magic getters + * @return array + */ + public function getGroups() + { + return $this->groups()->listGroupNames(); + } + + /** + * @return array + * @throws NotFoundError + */ + public function listInheritedGroupNames() + { + $parents = $this->imports()->getObjects(); + /** @var IcingaObject $parent */ + foreach (array_reverse($parents) as $parent) { + $inherited = $parent->getGroups(); + if (! empty($inherited)) { + return $inherited; + } + } + + return []; + } + + public function setGroups($groups) + { + $this->groups()->set($groups); + return $this; + } + + /** + * @return array + * @throws NotFoundError + */ + public function listResolvedGroupNames() + { + $groups = $this->groups()->listGroupNames(); + if (empty($groups)) { + return $this->listInheritedGroupNames(); + } + + return $groups; + } + + /** + * @param $group + * @return bool + * @throws NotFoundError + */ + public function hasGroup($group) + { + if ($group instanceof static) { + $group = $group->getObjectName(); + } + + return in_array($group, $this->listResolvedGroupNames()); + } + + protected function getResolved($what) + { + $func = 'resolve' . $what; + $res = $this->$func(); + return $res['_MERGED_']; + } + + protected function getInherited($what) + { + $func = 'resolve' . $what; + $res = $this->$func(); + return $res['_INHERITED_']; + } + + protected function getOrigins($what) + { + $func = 'resolve' . $what; + $res = $this->$func(); + return $res['_ORIGINS_']; + } + + protected function hasResolveCached($what) + { + return array_key_exists($what, $this->resolveCache); + } + + protected function & getResolveCached($what) + { + return $this->resolveCache[$what]; + } + + protected function storeResolvedCache($what, $vals) + { + $this->resolveCache[$what] = $vals; + } + + public function invalidateResolveCache() + { + $this->resolveCache = []; + return $this; + } + + public function countDirectDescendants() + { + $db = $this->getDb(); + $table = $this->getTableName(); + $type = $this->getShortTableName(); + + $query = $db->select()->from( + ['oi' => $table . '_inheritance'], + ['cnt' => 'COUNT(*)'] + )->where('oi.parent_' . $type . '_id = ?', (int) $this->get('id')); + + return $db->fetchOne($query); + } + + protected function triggerLoopDetection() + { + // $this->templateResolver()->listResolvedParentIds(); + } + + public function getSingleResolvedProperty($key, $default = null) + { + if (array_key_exists($key, $this->unresolvedRelatedProperties)) { + $this->resolveUnresolvedRelatedProperty($key); + $this->invalidateResolveCache(); + } + + if ($my = $this->get($key)) { + if ($my !== null) { + return $my; + } + } + + /** @var IcingaObject[] $imports */ + try { + $imports = array_reverse($this->imports()->getObjects()); + } catch (NotFoundError $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + // Eventually trigger loop detection + $this->listAncestorIds(); + + foreach ($imports as $object) { + $v = $object->getSingleResolvedProperty($key); + if (null !== $v) { + return $v; + } + } + + return $default; + } + + protected function resolve($what) + { + if ($this->hasResolveCached($what)) { + return $this->getResolveCached($what); + } + + // Force exception + if ($this->hasBeenLoadedFromDb()) { + $this->triggerLoopDetection(); + } + + $vals = []; + $vals['_MERGED_'] = (object) []; + $vals['_INHERITED_'] = (object) []; + $vals['_ORIGINS_'] = (object) []; + // $objects = $this->imports()->getObjects(); + $objects = IcingaTemplateRepository::instanceByObject($this) + ->getTemplatesIndexedByNameFor($this, true); + + $get = 'get' . $what; + $getInherited = 'getInherited' . $what; + $getOrigins = 'getOrigins' . $what; + + $blacklist = ['id', 'uuid', 'object_type', 'object_name', 'disabled']; + foreach ($objects as $name => $object) { + $origins = $object->$getOrigins(); + + foreach ($object->$getInherited() as $key => $value) { + if (in_array($key, $blacklist)) { + continue; + } + + if (! property_exists($origins, $key)) { + // TODO: Introduced with group membership resolver or + // choices - this should not be required. Check this! + continue; + } + + // $vals[$name]->$key = $value; + $vals['_MERGED_']->$key = $value; + $vals['_INHERITED_']->$key = $value; + $vals['_ORIGINS_']->$key = $origins->$key; + } + + foreach ($object->$get() as $key => $value) { + // TODO: skip if default value? + if ($value === null) { + continue; + } + if (in_array($key, $blacklist)) { + continue; + } + $vals['_MERGED_']->$key = $value; + $vals['_INHERITED_']->$key = $value; + $vals['_ORIGINS_']->$key = $name; + } + } + + foreach ($this->$get() as $key => $value) { + if ($value === null) { + continue; + } + + $vals['_MERGED_']->$key = $value; + } + + $this->storeResolvedCache($what, $vals); + + return $vals; + } + + public function matches(Filter $filter) + { + // TODO: speed up by passing only desired properties (filter columns) to + // toPlainObject method + /** @var FilterChain|FilterExpression $filter */ + return $filter->matches($this->toPlainObject()); + } + + protected function assertCustomVarsSupport() + { + if (! $this->supportsCustomVars()) { + throw new LogicException(sprintf( + 'Objects of type "%s" have no custom vars', + $this->getType() + )); + } + + return $this; + } + + protected function assertGroupsSupport() + { + if (! $this->supportsGroups()) { + throw new LogicException(sprintf( + 'Objects of type "%s" have no groups', + $this->getType() + )); + } + + return $this; + } + + protected function assertRangesSupport() + { + if (! $this->supportsRanges()) { + throw new LogicException(sprintf( + 'Objects of type "%s" have no ranges', + $this->getType() + )); + } + + return $this; + } + + protected function assertImportsSupport() + { + if (! $this->supportsImports()) { + throw new LogicException(sprintf( + 'Objects of type "%s" have no imports', + $this->getType() + )); + } + + return $this; + } + + /** + * @return CustomVariables + */ + public function vars() + { + $this->assertCustomVarsSupport(); + if ($this->vars === null) { + if ($this->hasBeenLoadedFromDb()) { + if (PrefetchCache::shouldBeUsed()) { + $this->vars = PrefetchCache::instance()->vars($this); + } else { + if ($this->get('id')) { + $this->vars = CustomVariables::loadForStoredObject($this); + } else { + $this->vars = new CustomVariables(); + } + } + + if ($this->getShortTableName() === 'host') { + $this->vars->setOverrideKeyName( + $this->getConnection()->settings()->override_services_varname + ); + } + } else { + $this->vars = new CustomVariables(); + } + } + + return $this->vars; + } + + /** + * @return bool + */ + public function hasInitializedVars() + { + $this->assertCustomVarsSupport(); + + return $this->vars !== null; + } + + public function getVarsTableName() + { + return $this->getTableName() . '_var'; + } + + public function getShortTableName() + { + // strlen('icinga_') = 7 + return substr($this->getTableName(), 7); + } + + public function getVarsIdColumn() + { + return $this->getShortTableName() . '_id'; + } + + public function hasProperty($key) + { + if ($this->propertyIsRelatedSet($key)) { + return true; + } + + if ($this->propertyIsMultiRelation($key)) { + return true; + } + + return parent::hasProperty($key); + } + + public function isObject() + { + return $this->hasProperty('object_type') + && $this->get('object_type') === 'object'; + } + + public function isTemplate() + { + return $this->hasProperty('object_type') + && $this->get('object_type') === 'template'; + } + + public function isExternal() + { + return $this->hasProperty('object_type') + && $this->get('object_type') === 'external_object'; + } + + public function isApplyRule() + { + return $this->hasProperty('object_type') + && $this->get('object_type') === 'apply'; + } + + public function setBeingLoadedFromDb() + { + if ($this instanceof ObjectWithArguments && $this->gotArguments()) { + $this->arguments()->setBeingLoadedFromDb(); + } + if ($this->supportsImports() && $this->gotImports()) { + $this->imports()->setBeingLoadedFromDb(); + } + if ($this->supportsCustomVars() && $this->vars !== null) { + $this->vars()->setBeingLoadedFromDb(); + } + if ($this->supportsGroups() && $this->groups !== null) { + $this->groups()->setBeingLoadedFromDb(); + } + if ($this->supportsRanges() && $this->ranges !== null) { + $this->ranges()->setBeingLoadedFromDb(); + } + + foreach ($this->loadedRelatedSets as $set) { + $set->setBeingLoadedFromDb(); + } + + foreach ($this->loadedMultiRelations as $multiRelation) { + $multiRelation->setBeingLoadedFromDb(); + } + // This might trigger DB requests and 404's. We might want to defer this, but a call to + // hasBeenModified triggers anyway: + $this->resolveUnresolvedRelatedProperties(); + + parent::setBeingLoadedFromDb(); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Zend_Db_Adapter_Exception + */ + protected function storeRelatedObjects() + { + $this + ->storeCustomVars() + ->storeGroups() + ->storeMultiRelations() + ->storeImports() + ->storeRanges() + ->storeRelatedSets() + ->storeArguments(); + } + + /** + * @throws NotFoundError + */ + protected function beforeStore() + { + $this->resolveUnresolvedRelatedProperties(); + if ($this->gotImports()) { + $this->imports()->getObjects(); + } + } + + /** + * @throws NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Zend_Db_Adapter_Exception + */ + public function onInsert() + { + DirectorActivityLog::logCreation($this, $this->connection); + $this->storeRelatedObjects(); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + * @throws \Zend_Db_Adapter_Exception + */ + public function onUpdate() + { + DirectorActivityLog::logModification($this, $this->connection); + $this->storeRelatedObjects(); + } + + public function onStore() + { + $this->notifyResolvers(); + } + + /** + * @return self + */ + protected function storeCustomVars() + { + if ($this->supportsCustomVars()) { + $this->vars !== null && $this->vars()->storeToDb($this); + } + + return $this; + } + + /** + * @return self + */ + protected function storeGroups() + { + if ($this->supportsGroups()) { + $this->groups !== null && $this->groups()->store(); + } + + return $this; + } + + /** + * @return self + */ + protected function storeMultiRelations() + { + foreach ($this->loadedMultiRelations as $rel) { + $rel->store(); + } + + return $this; + } + + /** + * @return self + */ + protected function storeRanges() + { + if ($this->supportsRanges()) { + $this->ranges !== null && $this->ranges()->store(); + } + + return $this; + } + + /** + * @return $this + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function storeArguments() + { + if ($this instanceof ObjectWithArguments) { + $this->gotArguments() && $this->arguments()->store(); + } + + return $this; + } + + protected function notifyResolvers() + { + } + + /** + * @return $this + */ + protected function storeRelatedSets() + { + foreach ($this->loadedRelatedSets as $set) { + if ($set->hasBeenModified()) { + $set->store(); + } + } + + return $this; + } + + /** + * @return $this + * @throws NotFoundError + * @throws \Zend_Db_Adapter_Exception + */ + protected function storeImports() + { + if ($this->supportsImports()) { + $this->imports !== null && $this->imports()->store(); + } + + return $this; + } + + public function beforeDelete() + { + $this->cachedPlainUnmodified = $this->getPlainUnmodifiedObject(); + } + + public function getCachedUnmodifiedObject() + { + return $this->cachedPlainUnmodified; + } + + public function onDelete() + { + DirectorActivityLog::logRemoval($this, $this->connection); + } + + public function toSingleIcingaConfig() + { + $config = new IcingaConfig($this->connection); + $object = $this; + if ($object->isExternal()) { + $object->set('object_type', 'object'); + $wasExternal = true; + } else { + $wasExternal = false; + } + + try { + $object->renderToConfig($config); + } catch (Exception $e) { + $message = $e->getMessage(); + $showTrace = false; + if ($showTrace) { + $message .= "\n" . $e->getTraceAsString(); + } + $config->configFile( + 'failed-to-render' + )->prepend( + "/** Failed to render this object **/\n" + . '/* ' . $message . ' */' + ); + } + if ($wasExternal) { + $object->set('object_type', 'external_object'); + } + + return $config; + } + + public function isSupportedInLegacy() + { + return $this->supportedInLegacy; + } + + public function renderToLegacyConfig(IcingaConfig $config) + { + if ($this->isExternal()) { + return; + } + + if (! $this->isSupportedInLegacy()) { + $config->configFile( + 'director/ignored-objects', + '.cfg' + )->prepend( + sprintf( + "# Not supported for legacy config: %s object_name=%s\n", + get_class($this), + $this->getObjectName() + ) + ); + return; + } + + $filename = $this->getRenderingFilename(); + + $deploymentMode = $config->getDeploymentMode(); + if ($deploymentMode === 'active-passive') { + if ($this->getSingleResolvedProperty('zone_id') + && array_key_exists('enable_active_checks', $this->defaultProperties) + ) { + $passive = clone($this); + $passive->set('enable_active_checks', false); + + $config->configFile( + 'director/master/' . $filename, + '.cfg' + )->addLegacyObject($passive); + } + } elseif ($deploymentMode === 'masterless') { + // no additional config + } else { + throw new LogicException(sprintf( + 'Unsupported deployment mode: %s', + $deploymentMode + )); + } + + $config->configFile( + 'director/' . $this->getRenderingZone($config) . '/' . $filename, + '.cfg' + )->addLegacyObject($this); + } + + public function renderToConfig(IcingaConfig $config) + { + if ($config->isLegacy()) { + $this->renderToLegacyConfig($config); + return; + } + + if ($this->isExternal()) { + return; + } + + $config->configFile( + 'zones.d/' . $this->getRenderingZone($config) . '/' . $this->getRenderingFilename() + )->addObject($this); + } + + public function getRenderingFilename() + { + $type = $this->getShortTableName(); + + if ($this->isTemplate()) { + $filename = strtolower($type) . '_templates'; + } elseif ($this->isApplyRule()) { + $filename = strtolower($type) . '_apply'; + } else { + $filename = strtolower($type) . 's'; + } + + return $filename; + } + + /** + * @param $zoneId + * @param IcingaConfig|null $config + * @return string + * @throws NotFoundError + */ + protected function getNameForZoneId($zoneId, IcingaConfig $config = null) + { + // TODO: this is still ugly. + if ($config === null) { + return IcingaZone::loadWithAutoIncId( + $zoneId, + $this->getConnection() + )->getObjectName(); + } + + // Config has a lookup cache, is faster: + return $config->getZoneName($zoneId); + } + + public function getRenderingZone(IcingaConfig $config = null) + { + if ($this->hasUnresolvedRelatedProperty('zone_id')) { + return $this->get('zone'); + } + + if ($this->hasProperty('zone_id')) { + try { + if (! $this->supportsImports()) { + if ($zoneId = $this->get('zone_id')) { + return $this->getNameForZoneId($zoneId, $config); + } + } + + if ($zoneId = $this->getSingleResolvedProperty('zone_id')) { + return $this->getNameForZoneId($zoneId, $config); + } + } catch (NestingError $e) { + throw $e; + } catch (Exception $e) { + return self::RESOLVE_ERROR; + } + } + + return $this->getDefaultZone($config); + } + + protected function getDefaultZone(IcingaConfig $config = null) + { + if ($this->prefersGlobalZone()) { + return $this->connection->getDefaultGlobalZoneName(); + } + + return $this->connection->getMasterZoneName(); + } + + protected function prefersGlobalZone() + { + return $this->isTemplate() || $this->isApplyRule(); + } + + protected function renderImports() + { + if (! $this->supportsImports()) { + return ''; + } + + $ret = ''; + foreach ($this->getImports() as $name) { + $ret .= ' import ' . c::renderString($name) . "\n"; + } + + if ($ret !== '') { + $ret .= "\n"; + } + + return $ret; + } + + protected function renderLegacyImports() + { + if ($this->supportsImports()) { + return $this->imports()->toLegacyConfigString(); + } + + return ''; + } + + protected function renderLegacyRelationProperty($propertyName, $id, $renderKey = null) + { + return $this->renderLegacyObjectProperty( + $renderKey ?: $propertyName, + c1::renderString($this->getRelatedObjectName($propertyName, $id)) + ); + } + + // Disabled is a virtual property + protected function renderDisabled() + { + return ''; + } + + /** + * @codingStandardsIgnoreStart + */ + protected function renderLegacyHost_id($value) + { + if (is_array($value)) { + return c1::renderKeyValue('host_name', c1::renderArray($value)); + } + + return $this->renderLegacyRelationProperty( + 'host', + $this->get('host_id'), + 'host_name' + ); + } + + /** + * Display Name only exists for host/service in Icinga 1 + * + * Render it as alias for everything by default. + * + * Alias does not exist in Icinga 2 currently! + * + * @return string + */ + protected function renderLegacyDisplay_Name() + { + return c1::renderKeyValue('alias', $this->display_name); + } + + protected function renderLegacyTimeout() + { + return ''; + } + + protected function renderLegacyEnable_active_checks() + { + return $this->renderLegacyBooleanProperty( + 'enable_active_checks', + 'active_checks_enabled' + ); + } + + protected function renderLegacyEnable_passive_checks() + { + return $this->renderLegacyBooleanProperty( + 'enable_passive_checks', + 'passive_checks_enabled' + ); + } + + protected function renderLegacyEnable_event_handler() + { + return $this->renderLegacyBooleanProperty( + 'enable_active_checks', + 'event_handler_enabled' + ); + } + + protected function renderLegacyEnable_notifications() + { + return $this->renderLegacyBooleanProperty( + 'enable_notifications', + 'notifications_enabled' + ); + } + + protected function renderLegacyEnable_perfdata() + { + return $this->renderLegacyBooleanProperty( + 'enable_perfdata', + 'process_perf_data' + ); + } + + protected function renderLegacyVolatile() + { + // @codingStandardsIgnoreEnd + return $this->renderLegacyBooleanProperty( + 'volatile', + 'is_volatile' + ); + } + + protected function renderLegacyBooleanProperty($property, $legacyKey) + { + return c1::renderKeyValue( + $legacyKey, + c1::renderBoolean($this->get($property)) + ); + } + + protected function renderProperties() + { + $out = ''; + $blacklist = array_merge( + $this->propertiesNotForRendering, + $this->prioritizedProperties + ); + + foreach ($this->properties as $key => $value) { + if (in_array($key, $blacklist)) { + continue; + } + + $out .= $this->renderObjectProperty($key, $value); + } + + return $out; + } + + protected function renderLegacyProperties() + { + $out = ''; + $blacklist = array_merge( + $this->propertiesNotForRendering, + [] /* $this->prioritizedProperties */ + ); + + foreach ($this->properties as $key => $value) { + if (in_array($key, $blacklist)) { + continue; + } + + $out .= $this->renderLegacyObjectProperty($key, $value); + } + + return $out; + } + + protected function renderPrioritizedProperties() + { + $out = ''; + + foreach ($this->prioritizedProperties as $key) { + $out .= $this->renderObjectProperty($key, $this->properties[$key]); + } + + return $out; + } + + protected function renderObjectProperty($key, $value) + { + if (substr($key, -3) === '_id') { + $short = substr($key, 0, -3); + if ($this->hasUnresolvedRelatedProperty($key)) { + return c::renderKeyValue( + $short, // NOT + c::renderString($this->$short) + ); + } + } + + if ($value === null) { + return ''; + } + + $method = 'render' . ucfirst($key); + if (method_exists($this, $method)) { + return $this->$method($value); + } + + if ($this->propertyIsBoolean($key)) { + if ($value === $this->defaultProperties[$key]) { + return ''; + } + + return c::renderKeyValue( + $this->booleans[$key], + c::renderBoolean($value) + ); + } + + if ($this->propertyIsInterval($key)) { + return c::renderKeyValue( + $this->intervalProperties[$key], + c::renderInterval($value) + ); + } + + if (substr($key, -3) === '_id' + && $this->hasRelation($relKey = substr($key, 0, -3)) + ) { + return $this->renderRelationProperty($relKey, $value); + } + + return c::renderKeyValue( + $key, + $this->isApplyRule() ? + c::renderStringWithVariables($value) : + c::renderString($value) + ); + } + + protected function renderLegacyObjectProperty($key, $value) + { + if (substr($key, -3) === '_id') { + $short = substr($key, 0, -3); + if ($this->hasUnresolvedRelatedProperty($key)) { + return c1::renderKeyValue( + $short, // NOT + c1::renderString($this->$short) + ); + } + } + + if ($value === null) { + return ''; + } + + $method = 'renderLegacy' . ucfirst($key); + if (method_exists($this, $method)) { + return $this->$method($value); + } + + $method = 'render' . ucfirst($key); + if (method_exists($this, $method)) { + return $this->$method($value); + } + + if ($this->propertyIsBoolean($key)) { + if ($value === $this->defaultProperties[$key]) { + return ''; + } + + return c1::renderKeyValue( + $this->booleans[$key], + c1::renderBoolean($value) + ); + } + + if ($this->propertyIsInterval($key)) { + return c1::renderKeyValue( + $this->intervalProperties[$key], + c1::renderInterval($value) + ); + } + + if (substr($key, -3) === '_id' + && $this->hasRelation($relKey = substr($key, 0, -3)) + ) { + return $this->renderLegacyRelationProperty($relKey, $value); + } + + return c1::renderKeyValue($key, c1::renderString($value)); + } + + protected function renderBooleanProperty($key) + { + return c::renderKeyValue($key, c::renderBoolean($this->get($key))); + } + + protected function renderPropertyAsSeconds($key) + { + return c::renderKeyValue($key, c::renderInterval($this->get($key))); + } + + protected function renderSuffix() + { + return "}\n\n"; + } + + protected function renderLegacySuffix() + { + return "}\n\n"; + } + + /** + * @return string + */ + protected function renderCustomVars() + { + if ($this->supportsCustomVars()) { + return $this->vars()->toConfigString($this->isApplyRule()); + } + + return ''; + } + + /** + * @return string + */ + protected function renderLegacyCustomVars() + { + if ($this->supportsCustomVars()) { + return $this->vars()->toLegacyConfigString(); + } + + return ''; + } + + public function renderUuid() + { + return ''; + } + + /** + * @return string + */ + protected function renderGroups() + { + if ($this->supportsGroups()) { + return $this->groups()->toConfigString(); + } + + return ''; + } + + /** + * @return string + */ + protected function renderLegacyGroups() + { + if ($this->supportsGroups() && $this->hasBeenLoadedFromDb()) { + $applied = []; + if ($this instanceof IcingaHost) { + $applied = $this->getAppliedGroups(); + } + return $this->groups()->toLegacyConfigString($applied); + } + + return ''; + } + + /** + * @return string + */ + protected function renderMultiRelations() + { + $out = ''; + foreach ($this->loadAllMultiRelations() as $rel) { + $out .= $rel->toConfigString(); + } + + return $out; + } + + /** + * @return string + */ + protected function renderLegacyMultiRelations() + { + $out = ''; + foreach ($this->loadAllMultiRelations() as $rel) { + $out .= $rel->toLegacyConfigString(); + } + + return $out; + } + + /** + * @return string + */ + protected function renderRanges() + { + if ($this->supportsRanges()) { + return $this->ranges()->toConfigString(); + } + + return ''; + } + + /** + * @return string + */ + protected function renderLegacyRanges() + { + if ($this->supportsRanges()) { + return $this->ranges()->toLegacyConfigString(); + } + + return ''; + } + + /** + * @return string + */ + protected function renderArguments() + { + return ''; + } + + protected function renderRelatedSets() + { + $config = ''; + foreach ($this->relatedSets as $property => $class) { + $config .= $this->getRelatedSet($property)->renderAs($property); + } + return $config; + } + + protected function renderRelationProperty($propertyName, $id, $renderKey = null) + { + return c::renderKeyValue( + $renderKey ?: $propertyName, + c::renderString($this->getRelatedObjectName($propertyName, $id)) + ); + } + + protected function renderCommandProperty($commandId, $propertyName = 'check_command') + { + return c::renderKeyValue( + $propertyName, + c::renderString($this->connection->getCommandName($commandId)) + ); + } + + /** + * @param $value + * @return string + * @codingStandardsIgnoreStart + */ + protected function renderLegacyCheck_command($value) + { + // @codingStandardsIgnoreEnd + $args = []; + foreach ($this->vars() as $k => $v) { + if (substr($k, 0, 3) === 'ARG') { + $args[] = $v->getValue(); + } + } + array_unshift($args, $value); + + return c1::renderKeyValue('check_command', implode('!', $args)); + } + + /** + * @param $value + * @return string + * @codingStandardsIgnoreStart + */ + protected function renderLegacyEvent_command($value) + { + // @codingStandardsIgnoreEnd + return c1::renderKeyValue('event_handler', $value); + } + + /** + * We do not render zone properties, objects are stored to zone dirs + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + protected function renderZone_id() + { + // @codingStandardsIgnoreEnd + return ''; + } + + protected function renderCustomExtensions() + { + return ''; + } + + protected function renderLegacyCustomExtensions() + { + $str = ''; + + // Set notification settings for the object to suppress warnings + if (array_key_exists('enable_notifications', $this->defaultProperties) + && $this->isTemplate() + ) { + $str .= c1::renderKeyValue('notification_period', 'notification_none'); + $str .= c1::renderKeyValue('notification_interval', '0'); + $str .= c1::renderKeyValue('contact_groups', 'icingaadmins'); + } + + // force rendering of check_command when ARG1 is set + if ($this->supportsCustomVars() && array_key_exists('check_command_id', $this->defaultProperties)) { + if ($this->get('check_command') === null + && $this->vars()->get('ARG1') !== null + ) { + $command = $this->getResolvedRelated('check_command'); + $str .= $this->renderLegacyCheck_command($command->getObjectName()); + } + } + + return $str; + } + + protected function renderObjectHeader() + { + return sprintf( + "%s %s %s {\n", + $this->getObjectTypeName(), + $this->getType(), + c::renderString($this->getObjectName()) + ); + } + + public function getLegacyObjectType() + { + return strtolower($this->getType()); + } + + protected function renderLegacyObjectHeader() + { + $type = $this->getLegacyObjectType(); + + if ($this->isTemplate()) { + $name = c1::renderKeyValue( + $this->getLegacyObjectKeyName(), + c1::renderString($this->getObjectName()) + ); + } else { + $name = c1::renderKeyValue( + $this->getLegacyObjectKeyName(), + c1::renderString($this->getObjectName()) + ); + } + + $str = "define $type {\n$name"; + if ($this->isTemplate()) { + $str .= c1::renderKeyValue('register', '0'); + } + + return $str; + } + + protected function getLegacyObjectKeyName() + { + if ($this->isTemplate()) { + return 'name'; + } + + return $this->getLegacyObjectType() . '_name'; + } + + /** + * @codingStandardsIgnoreStart + */ + public function renderAssign_Filter() + { + return ' ' . AssignRenderer::forFilter( + Filter::fromQueryString($this->get('assign_filter')) + )->renderAssign() . "\n"; + } + + public function renderLegacyAssign_Filter() + { + // @codingStandardsIgnoreEnd + if ($this instanceof IcingaHostGroup) { + $c = " # resolved memberships are set via the individual object\n"; + } elseif ($this instanceof IcingaService) { + $c = " # resolved objects are listed here\n"; + } else { + $c = " # assign is not supported for " . $this->type . "\n"; + } + $c .= ' #' . AssignRenderer::forFilter( + Filter::fromQueryString($this->get('assign_filter')) + )->renderAssign() . "\n"; + return $c; + } + + public function toLegacyConfigString() + { + $str = implode([ + $this->renderLegacyObjectHeader(), + $this->renderLegacyImports(), + $this->renderLegacyProperties(), + //$this->renderArguments(), + //$this->renderRelatedSets(), + $this->renderLegacyGroups(), + $this->renderLegacyMultiRelations(), + $this->renderLegacyRanges(), + $this->renderLegacyCustomExtensions(), + $this->renderLegacyCustomVars(), + $this->renderLegacySuffix() + ]); + + $str = $this->alignLegacyProperties($str); + + if ($this->isDisabled()) { + return + "# --- This object has been disabled ---\n" + . preg_replace('~^~m', '# ', trim($str)) + . "\n\n"; + } + + return $str; + } + + protected function alignLegacyProperties($configString) + { + $lines = explode("\n", $configString); + $len = 24; + + foreach ($lines as &$line) { + if (preg_match('/^\s{4}([^\t]+)\t+(.+)$/', $line, $m)) { + if ($len - strlen($m[1]) < 0) { + $fill = ' '; + } else { + $fill = str_repeat(' ', $len - strlen($m[1])); + } + + $line = ' ' . $m[1] . $fill . $m[2]; + } + } + + return implode("\n", $lines); + } + + public function toConfigString() + { + $str = implode([ + $this->renderObjectHeader(), + $this->renderPrioritizedProperties(), + $this->renderImports(), + $this->renderProperties(), + $this->renderArguments(), + $this->renderRelatedSets(), + $this->renderGroups(), + $this->renderMultiRelations(), + $this->renderRanges(), + $this->renderCustomExtensions(), + $this->renderCustomVars(), + $this->renderSuffix() + ]); + + if ($this->isDisabled()) { + return "/* --- This object has been disabled ---\n" + // Do not allow strings to break our comment + . str_replace('*/', "* /", $str) . "*/\n"; + } + + return $str; + } + + public function isGroup() + { + return substr($this->getType(), -5) === 'Group'; + } + + public function hasCheckCommand() + { + return false; + } + + protected function getType() + { + if ($this->type === null) { + $parts = explode('\\', get_class($this)); + // 6 = strlen('Icinga'); + $this->type = substr(end($parts), 6); + } + + return $this->type; + } + + protected function getObjectTypeName() + { + if ($this->isTemplate()) { + return 'template'; + } + if ($this->isApplyRule()) { + return 'apply'; + } + + return 'object'; + } + + public function getObjectName() + { + $property = static::getKeyColumnName(); + if ($this->hasProperty($property)) { + return $this->get($property); + } + + throw new LogicException(sprintf( + 'Trying to access "%s" for an instance of "%s"', + $property, + get_class($this) + )); + } + + /** + * @deprecated use DbObjectTypeRegistry::classByType() + * @param $type + * @return string + */ + public static function classByType($type) + { + return DbObjectTypeRegistry::classByType($type); + } + + /** + * @param $type + * @param array $properties + * @param Db|null $db + * + * @return IcingaObject + */ + public static function createByType($type, $properties = [], Db $db = null) + { + /** @var IcingaObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + return $class::create($properties, $db); + } + + /** + * @param $type + * @param $id + * @param Db $db + * + * @return IcingaObject + * @throws NotFoundError + */ + public static function loadByType($type, $id, Db $db) + { + /** @var IcingaObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + return $class::load($id, $db); + } + + /** + * @param $type + * @param $id + * @param Db $db + * + * @return bool + */ + public static function existsByType($type, $id, Db $db) + { + /** @var IcingaObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + return $class::exists($id, $db); + } + + public static function getKeyColumnName() + { + return 'object_name'; + } + + public static function loadAllByType($type, Db $db, $query = null, $keyColumn = null) + { + /** @var DbObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + + if ($keyColumn === null) { + if (method_exists($class, 'getKeyColumnName')) { + $keyColumn = $class::getKeyColumnName(); + } + } + + if (is_array($class::create()->getKeyName())) { + return $class::loadAll($db, $query); + } + + if (PrefetchCache::shouldBeUsed() + && $query === null + && $keyColumn === static::getKeyColumnName() + ) { + $result = []; + foreach ($class::prefetchAll($db) as $row) { + $result[$row->$keyColumn] = $row; + } + + return $result; + } + + return $class::loadAll($db, $query, $keyColumn); + } + + /** + * @param $type + * @param Db $db + * @return IcingaObject[] + */ + public static function loadAllExternalObjectsByType($type, Db $db) + { + /** @var IcingaObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + $dummy = $class::create(); + + if (is_array($dummy->getKeyName())) { + throw new LogicException(sprintf( + 'There is no support for loading external objects of type "%s"', + $type + )); + } + + $query = $db->getDbAdapter() + ->select() + ->from($dummy->getTableName()) + ->where('object_type = ?', 'external_object'); + + return $class::loadAll($db, $query, 'object_name'); + } + + public static function fromJson($json, Db $connection = null) + { + return static::fromPlainObject(json_decode($json), $connection); + } + + public static function fromPlainObject($plain, Db $connection = null) + { + return static::create((array) $plain, $connection); + } + + /** + * @param IcingaObject $object + * @param null $preserve + * @return $this + * @throws NotFoundError + */ + public function replaceWith(IcingaObject $object, $preserve = []) + { + return $this->replaceWithProperties($object->toPlainObject(), $preserve); + } + + /** + * @param array|object $properties + * @param array $preserve + * @return $this + * @throws NotFoundError + */ + public function replaceWithProperties($properties, $preserve = []) + { + $properties = (array) $properties; + foreach ($preserve as $k) { + $v = $this->get($k); + if ($v !== null) { + $properties[$k] = $v; + } + } + $this->setProperties($properties); + + return $this; + } + + /** + * TODO: with rules? What if I want to override vars? Drop in favour of vars.x? + * + * @param IcingaObject $object + * @param bool $replaceVars + * @return $this + * @throws NotFoundError + */ + public function merge(IcingaObject $object, $replaceVars = false) + { + $object = clone($object); + + if ($object->supportsCustomVars()) { + $vars = $object->getVars(); + $object->set('vars', []); + } + + if ($object->supportsGroups()) { + $groups = $object->getGroups(); + $object->set('groups', []); + } + + if ($object->supportsImports()) { + $imports = $object->listImportNames(); + $object->set('imports', []); + } + + $plain = (array) $object->toPlainObject(false, false); + unset($plain['vars'], $plain['groups'], $plain['imports']); + foreach ($plain as $p => $v) { + if ($v === null) { + // We want default values, but no null values + continue; + } + + $this->set($p, $v); + } + + if ($object->supportsCustomVars()) { + $myVars = $this->vars(); + if ($replaceVars) { + $this->set('vars', $vars); + } else { + /** @var CustomVariables $vars */ + foreach ($vars as $key => $var) { + $myVars->set($key, $var); + } + } + } + + if ($object->supportsGroups()) { + if (! empty($groups)) { + $this->set('groups', $groups); + } + } + + if ($object->supportsImports()) { + if (! empty($imports)) { + $this->set('imports', $imports); + } + } + + return $this; + } + + /** + * @param bool $resolved + * @param bool $skipDefaults + * @param array|null $chosenProperties + * @param bool $resolveIds + * @param bool $keepId + * @return object + * @throws NotFoundError + */ + public function toPlainObject( + $resolved = false, + $skipDefaults = false, + array $chosenProperties = null, + $resolveIds = true, + $keepId = false + ) { + $props = []; + + if ($resolved) { + $p = $this->getInheritedProperties(); + foreach ($this->properties as $k => $v) { + if ($v === null && property_exists($p, $k)) { + continue; + } + $p->$k = $v; + } + } else { + $p = $this->properties; + } + + foreach ($p as $k => $v) { + // Do not ship ids for IcingaObjects: + if ($k === $this->getUuidColumn()) { + continue; + } + if ($resolveIds) { + if ($k === 'id' && $keepId === false && $this->hasProperty('object_name')) { + continue; + } + + if ('_id' === substr($k, -3)) { + $relKey = substr($k, 0, -3); + + if ($this->hasRelation($relKey)) { + if ($this->hasUnresolvedRelatedProperty($k)) { + $v = $this->$relKey; + } elseif ($v !== null) { + $v = $this->getRelatedObjectName($relKey, $v); + } + + $k = $relKey; + } else { + throw new LogicException(sprintf( + 'No such relation: %s', + $relKey + )); + } + } + } + + // TODO: Do not ship null properties based on flag? + if (!$skipDefaults || $this->differsFromDefaultValue($k, $v)) { + if ($k === 'disabled' || $this->propertyIsBoolean($k)) { + $props[$k] = $this->booleanForDbValue($v); + } else { + $props[$k] = $v; + } + } + } + + if ($this->supportsGroups()) { + // TODO: resolve + $groups = $this->groups()->listGroupNames(); + if ($resolved && empty($groups)) { + $groups = $this->listInheritedGroupNames(); + } + + $props['groups'] = $groups; + } + + foreach ($this->loadAllMultiRelations() as $key => $rel) { + if (count($rel) || !$skipDefaults) { + $props[$key] = $rel->listRelatedNames(); + } + } + + if ($this instanceof ObjectWithArguments) { + $props['arguments'] = $this->arguments()->toPlainObject( + false, + $skipDefaults + ); + } + + if ($this->supportsCustomVars()) { + if ($resolved) { + $props['vars'] = $this->getResolvedVars(); + } else { + $props['vars'] = $this->getVars(); + } + } + + if ($this->supportsImports()) { + if ($resolved) { + $props['imports'] = []; + } else { + $props['imports'] = $this->listImportNames(); + } + } + + if ($this->supportsRanges()) { + // TODO: resolve + $props['ranges'] = $this->get('ranges'); + } + + if ($skipDefaults) { + foreach (['imports', 'ranges', 'arguments'] as $key) { + if (empty($props[$key])) { + unset($props[$key]); + } + } + + if (array_key_exists('vars', $props)) { + if (count((array) $props['vars']) === 0) { + unset($props['vars']); + } + } + if (empty($props['groups'])) { + unset($props['groups']); + } + } + + foreach ($this->relatedSets() as $property => $set) { + if ($resolved) { + if ($this->supportsImports()) { + $set = clone($set); + foreach ($this->imports()->getObjects() as $parent) { + $set->inheritFrom($parent->getRelatedSet($property)); + } + } + + $values = $set->getResolvedValues(); + if (empty($values)) { + if (!$skipDefaults) { + $props[$property] = null; + } + } else { + $props[$property] = $values; + } + } else { + if ($set->isEmpty()) { + if (!$skipDefaults) { + $props[$property] = null; + } + } else { + $props[$property] = $set->toPlainObject(); + } + } + } + + if ($chosenProperties !== null) { + $chosen = []; + foreach ($chosenProperties as $k) { + if (array_key_exists($k, $props)) { + $chosen[$k] = $props[$k]; + } + } + + $props = $chosen; + } + ksort($props); + + return (object) $props; + } + + protected function booleanForDbValue($value) + { + if ($value === 'y') { + return true; + } + if ($value === 'n') { + return false; + } + + return $value; // let this fail elsewhere, if not null + } + + public function listImportNames() + { + if ($this->gotImports()) { + return $this->imports()->listImportNames(); + } + + return $this->templateTree()->listParentNamesFor($this); + } + + public function listFlatResolvedImportNames() + { + return $this->templateTree()->getAncestorsFor($this); + } + + public function listImportIds() + { + return $this->templateTree()->listParentIdsFor($this); + } + + public function listAncestorIds() + { + return $this->templateTree()->listAncestorIdsFor($this); + } + + protected function templateTree() + { + return $this->templates()->tree(); + } + + protected function templates() + { + return IcingaTemplateRepository::instanceByObject($this, $this->getConnection()); + } + + protected function differsFromDefaultValue($key, $value) + { + if (array_key_exists($key, $this->defaultProperties)) { + return $value !== $this->defaultProperties[$key]; + } + + return $value !== null; + } + + protected function mapHostsToZones($names) + { + $map = []; + + foreach ($names as $hostname) { + /** @var IcingaHost $host */ + $host = IcingaHost::load($hostname, $this->connection); + + $zone = $host->getRenderingZone(); + if (! array_key_exists($zone, $map)) { + $map[$zone] = []; + } + + $map[$zone][] = $hostname; + } + + ksort($map); + + return $map; + } + + public function getUrlParams() + { + $params = []; + if ($column = $this->getUuidColumn()) { + return [$column => $this->getUniqueId()->toString()]; + } + + if ($this->isApplyRule() && ! $this instanceof IcingaScheduledDowntime) { + $params['id'] = $this->get('id'); + } else { + $params = ['name' => $this->getObjectName()]; + + if ($this->hasProperty('host_id') && $this->get('host_id')) { + $params['host'] = $this->get('host'); + } + + if ($this->hasProperty('service_id') && $this->get('service_id')) { + $params['service'] = $this->get('service'); + } + + if ($this->hasProperty('service_set_id') && $this->get('service_set_id')) { + $params['set'] = $this->get('service_set'); + } + } + + return $params; + } + + public function getOnDeleteUrl() + { + $plural= preg_replace('/cys$/', 'cies', strtolower($this->getShortTableName()) . 's'); + return 'director/' . $plural; + } + + /** + * @param bool $resolved + * @param bool $skipDefaults + * @param array|null $chosenProperties + * @return string + * @throws NotFoundError + */ + public function toJson( + $resolved = false, + $skipDefaults = false, + array $chosenProperties = null + ) { + + return json_encode($this->toPlainObject($resolved, $skipDefaults, $chosenProperties)); + } + + public function getPlainUnmodifiedObject() + { + $props = []; + + foreach ($this->getOriginalProperties() as $k => $v) { + // Do not ship ids for IcingaObjects: + if ($k === 'id' && $this->hasProperty('object_name')) { + continue; + } + if ($k === $this->getUuidColumn()) { + continue; + } + if ($k === 'disabled' && $v === null) { + continue; + } + + if ('_id' === substr($k, -3)) { + $relKey = substr($k, 0, -3); + + if ($this->hasRelation($relKey)) { + if ($v !== null) { + $v = $this->getRelatedObjectName($relKey, $v); + } + + $k = $relKey; + } + } + + if ($this->differsFromDefaultValue($k, $v)) { + if ($k === 'disabled' || $this->propertyIsBoolean($k)) { + $props[$k] = $this->booleanForDbValue($v); + } else { + $props[$k] = $v; + } + } + } + + if ($this->supportsCustomVars()) { + $originalVars = $this->vars()->getOriginalVars(); + if (! empty($originalVars)) { + $props['vars'] = (object) []; + foreach ($originalVars as $name => $var) { + $props['vars']->$name = $var->getValue(); + } + } + } + if ($this->supportsGroups()) { + $groups = $this->groups()->listOriginalGroupNames(); + if (! empty($groups)) { + $props['groups'] = $groups; + } + } + if ($this->supportsImports()) { + $imports = $this->imports()->listOriginalImportNames(); + if (! empty($imports)) { + $props['imports'] = $imports; + } + } + + if ($this instanceof ObjectWithArguments) { + $args = $this->arguments()->toUnmodifiedPlainObject(); + if (! empty($args)) { + $props['arguments'] = $args; + } + } + + if ($this->supportsRanges()) { + $ranges = $this->ranges()->getOriginalValues(); + if (!empty($ranges)) { + $props['ranges'] = $ranges; + } + } + + foreach ($this->relatedSets() as $property => $set) { + if ($set->isEmpty()) { + continue; + } + + $props[$property] = $set->getPlainUnmodifiedObject(); + } + + foreach ($this->loadAllMultiRelations() as $key => $rel) { + $old = $rel->listOriginalNames(); + if (! empty($old)) { + $props[$key] = $old; + } + } + + return (object) $props; + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } + + die($e->getMessage()); + } + } + + public function __destruct() + { + unset($this->resolveCache); + unset($this->vars); + unset($this->groups); + unset($this->imports); + unset($this->ranges); + if ($this instanceof ObjectWithArguments) { + $this->unsetArguments(); + } + + parent::__destruct(); + } +} diff --git a/library/Director/Objects/IcingaObjectField.php b/library/Director/Objects/IcingaObjectField.php new file mode 100644 index 0000000..e18965b --- /dev/null +++ b/library/Director/Objects/IcingaObjectField.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\Db\DbObject; + +abstract class IcingaObjectField extends DbObject +{ + /** + * + * @param Filter|string $filter + * + * @return $this + * @codingStandardsIgnoreStart + */ + protected function setVar_filter($value) + { + // @codingStandardsIgnoreEnd + if ($value instanceof Filter) { + $value = $value->toQueryString(); + } + + return $this->reallySet('var_filter', $value); + } +} diff --git a/library/Director/Objects/IcingaObjectGroup.php b/library/Director/Objects/IcingaObjectGroup.php new file mode 100644 index 0000000..c0bec54 --- /dev/null +++ b/library/Director/Objects/IcingaObjectGroup.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +abstract class IcingaObjectGroup extends IcingaObject implements ExportInterface +{ + protected $supportsImports = true; + + protected $supportedInLegacy = true; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'display_name' => null, + 'assign_filter' => null, + ]; + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + return $this->toPlainObject(); + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaObjectGroup + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Group "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $object->setProperties($properties); + + return $object; + } + + protected function prefersGlobalZone() + { + return true; + } +} diff --git a/library/Director/Objects/IcingaObjectGroups.php b/library/Director/Objects/IcingaObjectGroups.php new file mode 100644 index 0000000..8bef1b1 --- /dev/null +++ b/library/Director/Objects/IcingaObjectGroups.php @@ -0,0 +1,408 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Countable; +use Exception; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Iterator; +use RuntimeException; + +class IcingaObjectGroups implements Iterator, Countable, IcingaConfigRenderer +{ + protected $storedGroups = array(); + + protected $groups = array(); + + protected $modified = false; + + protected $object; + + private $position = 0; + + protected $idx = array(); + + public function __construct(IcingaObject $object) + { + $this->object = $object; + + if (! $object->hasBeenLoadedFromDb() && PrefetchCache::shouldBeUsed()) { + /** @var IcingaObjectGroup $class */ + $class = $this->getGroupClass(); + $class::prefetchAll($this->object->getConnection()); + } + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->groups); + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + public function hasBeenModified() + { + return $this->modified; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->groups[$this->idx[$this->position]]; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + public function get($key) + { + if (array_key_exists($key, $this->groups)) { + return $this->groups[$key]; + } + + return null; + } + + /** + * @param $group + * @return $this + * @throws NotFoundError + */ + public function set($group) + { + if (! is_array($group)) { + $group = array($group); + } + + $existing = array_keys($this->groups); + $new = array(); + $class = $this->getGroupClass(); + $unset = array(); + + foreach ($group as $k => $g) { + if ($g instanceof $class) { + $new[] = $g->object_name; + } else { + if (empty($g)) { + $unset[] = $k; + continue; + } + + $new[] = $g; + } + } + + foreach ($unset as $k) { + unset($group[$k]); + } + + sort($existing); + sort($new); + if ($existing === $new) { + return $this; + } + + $this->groups = array(); + if (empty($group)) { + $this->modified = true; + $this->refreshIndex(); + return $this; + } + + return $this->add($group); + } + + /** + * Magic isset check + * + * @return boolean + */ + public function __isset($group) + { + return array_key_exists($group, $this->groups); + } + + public function remove($group) + { + if (array_key_exists($group, $this->groups)) { + unset($this->groups[$group]); + } + + $this->modified = true; + $this->refreshIndex(); + } + + protected function refreshIndex() + { + ksort($this->groups); + $this->idx = array_keys($this->groups); + } + + /** + * @param $group + * @param string $onError + * @return $this + * @throws NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function add($group, $onError = 'fail') + { + // TODO: only one query when adding array + if (is_array($group)) { + foreach ($group as $g) { + $this->add($g, $onError); + } + return $this; + } + + /** @var IcingaObjectGroup $class */ + $class = $this->getGroupClass(); + + if ($group instanceof $class) { + if (array_key_exists($group->getObjectName(), $this->groups)) { + return $this; + } + + $this->groups[$group->object_name] = $group; + } elseif (is_string($group)) { + if (array_key_exists($group, $this->groups)) { + return $this; + } + + $connection = $this->object->getConnection(); + + try { + $this->groups[$group] = $class::load($group, $connection); + } catch (NotFoundError $e) { + switch ($onError) { + case 'autocreate': + $newGroup = $class::create(array( + 'object_type' => 'object', + 'object_name' => $group + )); + $newGroup->store($connection); + $this->groups[$group] = $newGroup; + break; + case 'fail': + throw new NotFoundError( + 'The group "%s" doesn\'t exist.', + $group + ); + break; + case 'ignore': + return $this; + } + } + } else { + throw new RuntimeException( + 'Invalid group object: %s', + var_export($group, 1) + ); + } + + $this->modified = true; + $this->refreshIndex(); + + return $this; + } + + protected function getGroupTableName() + { + return $this->object->getTableName() . 'group'; + } + + + protected function getGroupMemberTableName() + { + return $this->object->getTableName() . 'group_' . $this->getType(); + } + + public function listGroupNames() + { + return array_keys($this->groups); + } + + public function listOriginalGroupNames() + { + return array_keys($this->storedGroups); + } + + public function getType() + { + return $this->object->getShortTableName(); + } + + protected function loadFromDb() + { + $db = $this->object->getDb(); + $connection = $this->object->getConnection(); + + $type = $this->getType(); + + $table = $this->object->getTableName(); + $query = $db->select()->from( + array('go' => $table . 'group_' . $type), + array() + )->join( + array('g' => $table . 'group'), + 'go.' . $type . 'group_id = g.id', + '*' + )->where('go.' . $type . '_id = ?', $this->object->id) + ->order('g.object_name'); + + $class = $this->getGroupClass(); + $this->groups = $class::loadAll($connection, $query, 'object_name'); + $this->setBeingLoadedFromDb(); + + return $this; + } + + public function store() + { + $storedGroups = array_keys($this->storedGroups); + $groups = array_keys($this->groups); + + $objectId = $this->object->id; + $type = $this->getType(); + + $objectCol = $type . '_id'; + $groupCol = $type . 'group_id'; + + $toDelete = array_diff($storedGroups, $groups); + foreach ($toDelete as $group) { + $where = sprintf( + $objectCol . ' = %d AND ' . $groupCol . ' = %d', + $objectId, + $this->storedGroups[$group]->id + ); + + $this->object->db->delete( + $this->getGroupMemberTableName(), + $where + ); + } + + $toAdd = array_diff($groups, $storedGroups); + foreach ($toAdd as $group) { + $this->object->db->insert( + $this->getGroupMemberTableName(), + array( + $objectCol => $objectId, + $groupCol => $this->groups[$group]->id + ) + ); + } + $this->setBeingLoadedFromDb(); + + return true; + } + + public function setBeingLoadedFromDb() + { + $this->storedGroups = array(); + foreach ($this->groups as $k => $v) { + $this->storedGroups[$k] = clone($v); + $this->storedGroups[$k]->id = $v->id; + } + + $this->modified = false; + } + + protected function getGroupClass() + { + return __NAMESPACE__ . '\\Icinga' .ucfirst($this->object->getShortTableName()) . 'Group'; + } + + public static function loadForStoredObject(IcingaObject $object) + { + $groups = new static($object); + + if (PrefetchCache::shouldBeUsed()) { + $groups->groups = PrefetchCache::instance()->groups($object); + $groups->setBeingLoadedFromDb(); + } else { + $groups->loadFromDb(); + } + + return $groups; + } + + public function toConfigString() + { + $groups = array_keys($this->groups); + + if (empty($groups)) { + return ''; + } + + return c::renderKeyValue('groups', c::renderArray($groups)); + } + + public function toLegacyConfigString($additionalGroups = array()) + { + $groups = array_merge(array_keys($this->groups), $additionalGroups); + $groups = array_unique($groups); + + if (empty($groups)) { + return ''; + } + + $type = $this->object->getLegacyObjectType(); + return c1::renderKeyValue($type.'groups', c1::renderArray($groups)); + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } else { + die($e->getMessage()); + } + } + } + + public function __destruct() + { + unset($this->storedGroups); + unset($this->groups); + unset($this->object); + } +} diff --git a/library/Director/Objects/IcingaObjectImports.php b/library/Director/Objects/IcingaObjectImports.php new file mode 100644 index 0000000..384fa1c --- /dev/null +++ b/library/Director/Objects/IcingaObjectImports.php @@ -0,0 +1,439 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Countable; +use Exception; +use Icinga\Exception\NotFoundError; +use Iterator; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\Repository\IcingaTemplateRepository; +use RuntimeException; + +class IcingaObjectImports implements Iterator, Countable, IcingaConfigRenderer +{ + protected $storedNames = []; + + /** @var array A list of our imports, key and value are the import name */ + protected $imports = []; + + /** @var IcingaObject[] A list of all objects we have seen, referred by name */ + protected $objects = []; + + protected $modified = false; + + /** @var IcingaObject The parent object */ + protected $object; + + private $position = 0; + + protected $idx = []; + + public function __construct(IcingaObject $object) + { + $this->object = $object; + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->imports); + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + public function setModified() + { + $this->modified = true; + return $this; + } + + public function hasBeenModified() + { + return $this->modified; + } + + /** + * @return IcingaObject|null + * @throws \Icinga\Exception\NotFoundError + */ + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->getObject( + $this->imports[$this->idx[$this->position]] + ); + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + /** + * @param $key + * @return IcingaObject|null + * @throws \Icinga\Exception\NotFoundError + */ + public function get($key) + { + if (array_key_exists($key, $this->imports)) { + return $this->getObject($this->imports[$key]); + } + + return null; + } + + public function set($import) + { + if (empty($import)) { + if (empty($this->imports)) { + return $this; + } else { + return $this->clear(); + } + } + + if (! is_array($import)) { + $import = [$import]; + } + + $existing = $this->listImportNames(); + $new = $this->listNamesForGivenImports($import); + + if ($existing === $new) { + return $this; + } + + $this->imports = []; + return $this->add($import); + } + + protected function listNamesForGivenImports($imports) + { + $list = []; + $class = $this->getImportClass(); + + foreach ($imports as $i) { + if ($i instanceof $class) { + $list[] = $i->object_name; + } else { + $list[] = $i; + } + } + + return $list; + } + + /** + * Magic isset check + * + * @param string $import + * + * @return boolean + */ + public function __isset($import) + { + return array_key_exists($import, $this->imports); + } + + public function clear() + { + if ($this->imports === []) { + return $this; + } + + $this->imports = []; + $this->modified = true; + + return $this->refreshIndex(); + } + + public function remove($import) + { + if (array_key_exists($import, $this->imports)) { + unset($this->imports[$import]); + } + + $this->modified = true; + + return $this->refreshIndex(); + } + + protected function refreshIndex() + { + $this->idx = array_keys($this->imports); + // $this->object->templateResolver()->refreshObject($this->object); + + return $this; + } + + public function add($import) + { + $class = $this->getImportClass(); + + if (is_array($import)) { + foreach ($import as $i) { + // Gracefully ignore null members or empty strings + if (! $i instanceof $class && ($i === null || strlen($i) === 0)) { + continue; + } + + $this->add($i); + } + + return $this; + } + + if ($import instanceof $class) { + $name = $import->object_name; + if (array_key_exists($name, $this->imports)) { + return $this; + } + + $this->imports[$name] = $name; + $this->objects[$name] = $import; + } elseif (is_string($import)) { + if (array_key_exists($import, $this->imports)) { + return $this; + } + + $this->imports[$import] = $import; + } + + $this->modified = true; + $this->refreshIndex(); + + return $this; + } + + /** + * @return IcingaObject[] + * @throws \Icinga\Exception\NotFoundError + */ + public function getObjects() + { + $list = []; + foreach ($this->listImportNames() as $name) { + $name = (string) $name; + $list[$name] = $this->getObject($name); + } + + return $list; + } + + /** + * @param $name + * @return IcingaObject + * @throws \Icinga\Exception\NotFoundError + */ + protected function getObject($name) + { + if (array_key_exists($name, $this->objects)) { + return $this->objects[$name]; + } + + $connection = $this->object->getConnection(); + /** @var IcingaObject $class */ + $class = $this->getImportClass(); + try { + if (is_array($this->object->getKeyName())) { + // Services only + $import = $class::load([ + 'object_name' => $name, + 'object_type' => 'template' + ], $connection); + } else { + $import = $class::load($name, $connection); + } + } catch (NotFoundError $e) { + throw new NotFoundError(sprintf( + 'Unable to load parent referenced from %s "%s", %s', + $this->object->getShortTableName(), + $this->object->getObjectName(), + lcfirst($e->getMessage()) + ), $e->getCode(), $e); + } + + return $this->objects[$import->getObjectName()] = $import; + } + + protected function getImportTableName() + { + return $this->object->getTableName() . '_inheritance'; + } + + public function listImportNames() + { + return array_keys($this->imports); + } + + public function listOriginalImportNames() + { + return $this->storedNames; + } + + public function getType() + { + return $this->object->getShortTableName(); + } + + protected function loadFromDb() + { + // $resolver = $this->object->templateResolver(); + // $this->objects = $resolver->fetchParents(); + $this->objects = IcingaTemplateRepository::instanceByObject($this->object) + ->getTemplatesIndexedByNameFor($this->object); + if (empty($this->objects)) { + $this->imports = []; + } else { + $keys = array_keys($this->objects); + $this->imports = array_combine($keys, $keys); + } + + $this->setBeingLoadedFromDb(); + return $this; + } + + /** + * @return bool + * @throws \Zend_Db_Adapter_Exception + * @throws \Icinga\Exception\NotFoundError + */ + public function store() + { + if (! $this->hasBeenModified()) { + return true; + } + + $objectId = $this->object->get('id'); + if ($objectId === null) { + throw new RuntimeException( + 'Cannot store imports for unstored object with no ID' + ); + } else { + $objectId = (int) $objectId; + } + + $type = $this->getType(); + + $objectCol = $type . '_id'; + $importCol = 'parent_' . $type . '_id'; + $table = $this->getImportTableName(); + $db = $this->object->getDb(); + + if ($this->object->hasBeenLoadedFromDb()) { + $db->delete( + $table, + $objectCol . ' = ' . $objectId + ); + } + + $weight = 1; + foreach ($this->getObjects() as $import) { + $db->insert($table, [ + $objectCol => $objectId, + $importCol => $import->get('id'), + 'weight' => $weight++ + ]); + } + + $this->setBeingLoadedFromDb(); + + return true; + } + + public function setBeingLoadedFromDb() + { + $this->storedNames = $this->listImportNames(); + $this->modified = false; + } + + protected function getImportClass() + { + return get_class($this->object); + } + + public static function loadForStoredObject(IcingaObject $object) + { + $obj = new static($object); + return $obj->loadFromDb(); + } + + public function toConfigString() + { + $ret = ''; + + foreach ($this->listImportNames() as $name) { + $ret .= ' import ' . c::renderString($name) . "\n"; + } + + if ($ret !== '') { + $ret .= "\n"; + } + return $ret; + } + + public function toLegacyConfigString() + { + $ret = ''; + + foreach ($this->listImportNames() as $name) { + $ret .= c1::renderKeyValue('use', c1::renderString($name)) . "\n"; + } + + if ($ret !== '') { + $ret .= "\n"; + } + return $ret; + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } else { + die($e->getMessage()); + } + } + } + + public function __destruct() + { + unset($this->object); + unset($this->objects); + } +} diff --git a/library/Director/Objects/IcingaObjectLegacyAssignments.php b/library/Director/Objects/IcingaObjectLegacyAssignments.php new file mode 100644 index 0000000..6ab75c8 --- /dev/null +++ b/library/Director/Objects/IcingaObjectLegacyAssignments.php @@ -0,0 +1,79 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use LogicException; + +/** + * This class is required for historical reasons + * + * Objects with assignments in your activity log would otherwise not be able + * to render themselves + */ +class IcingaObjectLegacyAssignments +{ + public static function applyToObject(IcingaObject $object, $values) + { + if (! $object->supportsAssignments()) { + throw new LogicException(sprintf( + 'I can only assign for applied objects, got %s', + $object->object_type + )); + } + + if ($values === null) { + return $object; + } + + if (! is_array($values)) { + static::throwCompatError(); + } + + if (empty($values)) { + return $object; + } + + $assigns = array(); + $ignores = array(); + foreach ($values as $type => $value) { + if (strpos($value, '|') !== false || strpos($value, '&' !== false)) { + $value = '(' . $value . ')'; + } + + if ($type === 'assign') { + $assigns[] = $value; + } elseif ($type === 'ignore') { + $ignores[] = $value; + } else { + static::throwCompatError(); + } + } + + $assign = implode('|', $assigns); + $ignore = implode('&', $ignores); + if (empty($assign)) { + $filter = $ignore; + } elseif (empty($ignore)) { + $filter = $assign; + } else { + if (count($assigns) === 1) { + $filter = $assign . '&' . $ignore; + } else { + $filter = '(' . $assign . ')&(' . $ignore . ')'; + } + } + + $object->assign_filter = $filter; + + return $object; + } + + protected static function throwCompatError() + { + throw new LogicException( + 'You ran into an unexpected compatibility issue. Please report' + . ' this with details helping us to reproduce this to the' + . ' Icinga project' + ); + } +} diff --git a/library/Director/Objects/IcingaObjectMultiRelations.php b/library/Director/Objects/IcingaObjectMultiRelations.php new file mode 100644 index 0000000..a1ec9a2 --- /dev/null +++ b/library/Director/Objects/IcingaObjectMultiRelations.php @@ -0,0 +1,454 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Exception\ProgrammingError; +use Iterator; +use Countable; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; + +class IcingaObjectMultiRelations implements Iterator, Countable, IcingaConfigRenderer +{ + protected $stored = array(); + + protected $relations = array(); + + protected $modified = false; + + protected $object; + + protected $propertyName; + + protected $relatedObjectClass; + + protected $relatedTableName; + + protected $relationIdColumn; + + protected $relatedShortName; + + protected $legacyPropertyName; + + private $position = 0; + + private $db; + + protected $idx = array(); + + public function __construct(IcingaObject $object, $propertyName, $config) + { + $this->object = $object; + $this->propertyName = $propertyName; + + if (is_object($config) || is_array($config)) { + foreach ($config as $k => $v) { + $this->$k = $v; + } + } else { + $this->relatedObjectClass = $config; + } + } + + public function getObjects() + { + return $this->relations; + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->relations); + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + public function hasBeenModified() + { + return $this->modified; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->relations[$this->idx[$this->position]]; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + public function get($key) + { + if (array_key_exists($key, $this->relations)) { + return $this->relations[$key]; + } + + return null; + } + + public function set($relation) + { + if (! is_array($relation)) { + if ($relation === null) { + $relation = array(); + } else { + $relation = array($relation); + } + } + + $existing = array_keys($this->relations); + $new = array(); + $class = $this->getRelatedClassName(); + $unset = array(); + + foreach ($relation as $k => $ro) { + if ($ro instanceof $class) { + $new[] = $ro->object_name; + } else { + if (empty($ro)) { + $unset[] = $k; + continue; + } + + $new[] = $ro; + } + } + + foreach ($unset as $k) { + unset($relation[$k]); + } + + sort($existing); + sort($new); + if ($existing === $new) { + return $this; + } + + $this->relations = array(); + if (empty($relation)) { + $this->modified = true; + $this->refreshIndex(); + return $this; + } + + return $this->add($relation); + } + + /** + * Magic isset check + * + * @return boolean + */ + public function __isset($relation) + { + return array_key_exists($relation, $this->relations); + } + + public function remove($relation) + { + if (array_key_exists($relation, $this->relations)) { + unset($this->relations[$relation]); + } + + $this->modified = true; + $this->refreshIndex(); + } + + protected function refreshIndex() + { + ksort($this->relations); + $this->idx = array_keys($this->relations); + } + + public function add($relation, $onError = 'fail') + { + // TODO: only one query when adding array + if (is_array($relation)) { + foreach ($relation as $r) { + $this->add($r, $onError); + } + return $this; + } + + if (array_key_exists($relation, $this->relations)) { + return $this; + } + + $class = $this->getRelatedClassName(); + + if ($relation instanceof $class) { + $this->relations[$relation->object_name] = $relation; + } elseif (is_string($relation)) { + $connection = $this->object->getConnection(); + try { + // Related services can only be objects, used by ServiceSets + if ($class === 'Icinga\\Module\\Director\\Objects\\IcingaService') { + $relation = $class::load(array( + 'object_name' => $relation, + 'object_type' => 'template' + ), $connection); + } else { + $relation = $class::load($relation, $connection); + } + } catch (Exception $e) { + switch ($onError) { + case 'autocreate': + $relation = $class::create(array( + 'object_type' => 'object', + 'object_name' => $relation + )); + $relation->store($connection); + // TODO + case 'fail': + throw new ProgrammingError( + 'The related %s "%s" doesn\'t exists: %s', + $this->getRelatedTableName(), + $relation, + $e->getMessage() + ); + break; + case 'ignore': + return $this; + } + } + } else { + throw new ProgrammingError( + 'Invalid related object: %s', + var_export($relation, 1) + ); + } + + $this->relations[$relation->object_name] = $relation; + $this->modified = true; + $this->refreshIndex(); + + return $this; + } + + protected function getPropertyName() + { + return $this->propertyName; + } + + protected function getRelatedShortName() + { + if ($this->relatedShortName === null) { + /** @var IcingaObject $class */ + $class = $this->getRelatedClassName(); + $this->relatedShortName = $class::create()->getShortTableName(); + } + + return $this->relatedShortName; + } + + protected function getTableName() + { + return $this->object->getTableName() . '_' . $this->getRelatedShortName(); + } + + protected function getRelatedTableName() + { + if ($this->relatedTableName === null) { + /** @var IcingaObject $class */ + $class = $this->getRelatedClassName(); + $this->relatedTableName = $class::create()->getTableName(); + } + + return $this->relatedTableName; + } + + protected function getRelationIdColumn() + { + if ($this->relationIdColumn === null) { + $this->relationIdColumn = $this->getRelatedShortName(); + } + + return $this->relationIdColumn; + } + + public function listRelatedNames() + { + return array_keys($this->relations); + } + + public function listOriginalNames() + { + return array_keys($this->stored); + } + + public function getType() + { + return $this->object->getShortTableName(); + } + + protected function loadFromDb() + { + $db = $this->getDb(); + $connection = $this->object->getConnection(); + + $type = $this->getType(); + $objectIdCol = $type . '_id'; + $relationIdCol = $this->getRelationIdColumn() . '_id'; + + $query = $db->select()->from( + array('r' => $this->getTableName()), + array() + )->join( + array('ro' => $this->getRelatedTableName()), + sprintf('r.%s = ro.id', $relationIdCol), + '*' + )->where( + sprintf('r.%s = ?', $objectIdCol), + (int) $this->object->id + )->order('ro.object_name'); + + $class = $this->getRelatedClassName(); + $this->relations = $class::loadAll($connection, $query, 'object_name'); + $this->setBeingLoadedFromDb(); + + return $this; + } + + public function store() + { + $db = $this->getDb(); + $stored = array_keys($this->stored); + $relations = array_keys($this->relations); + + $objectId = $this->object->id; + $type = $this->getType(); + $objectCol = $type . '_id'; + $relationCol = $this->getRelationIdColumn() . '_id'; + + $toDelete = array_diff($stored, $relations); + foreach ($toDelete as $relation) { + // We work with cloned objects. (why?) + // As __clone drops the id, we need to access original properties + $orig = $this->stored[$relation]->getOriginalProperties(); + $where = sprintf( + $objectCol . ' = %d AND ' . $relationCol . ' = %d', + $objectId, + $orig['id'] + ); + + $db->delete( + $this->getTableName(), + $where + ); + } + + $toAdd = array_diff($relations, $stored); + foreach ($toAdd as $related) { + $db->insert( + $this->getTableName(), + array( + $objectCol => $objectId, + $relationCol => $this->relations[$related]->id + ) + ); + } + $this->setBeingLoadedFromDb(); + + return true; + } + + public function setBeingLoadedFromDb() + { + $this->stored = array(); + foreach ($this->relations as $k => $v) { + $this->stored[$k] = clone($v); + } + } + + protected function getRelatedClassName() + { + return __NAMESPACE__ . '\\' . $this->relatedObjectClass; + } + + protected function getDb() + { + if ($this->db === null) { + $this->db = $this->object->getDb(); + } + + return $this->db; + } + + public static function loadForStoredObject(IcingaObject $object, $propertyName, $relatedObjectClass) + { + $relations = new static($object, $propertyName, $relatedObjectClass); + return $relations->loadFromDb(); + } + + public function toConfigString() + { + $relations = array_keys($this->relations); + + if (empty($relations)) { + return ''; + } + + return c::renderKeyValue($this->propertyName, c::renderArray($relations)); + } + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } else { + die($e->getMessage()); + } + } + } + + public function toLegacyConfigString() + { + $relations = array_keys($this->relations); + + if (empty($relations)) { + return ''; + } + + if ($this->legacyPropertyName === null) { + return ' # not supported in legacy: ' . + c1::renderKeyValue($this->propertyName, c1::renderArray($relations), ''); + } + + return c1::renderKeyValue($this->legacyPropertyName, c1::renderArray($relations)); + } +} diff --git a/library/Director/Objects/IcingaRanges.php b/library/Director/Objects/IcingaRanges.php new file mode 100644 index 0000000..c14c588 --- /dev/null +++ b/library/Director/Objects/IcingaRanges.php @@ -0,0 +1,321 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; + +abstract class IcingaRanges +{ + /** @var IcingaTimePeriodRange[]|IcingaScheduledDowntimeRange[] */ + protected $storedRanges = []; + + /** @var IcingaTimePeriodRange[]|IcingaScheduledDowntimeRange[] */ + protected $ranges = []; + + protected $modified = false; + + protected $object; + + private $position = 0; + + protected $idx = array(); + + public function __construct(IcingaObject $object) + { + $this->object = $object; + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->ranges); + } + + #[\ReturnTypeWillChange] + public function rewind() + { + $this->position = 0; + } + + public function hasBeenModified() + { + return $this->modified; + } + + #[\ReturnTypeWillChange] + public function current() + { + if (! $this->valid()) { + return null; + } + + return $this->ranges[$this->idx[$this->position]]; + } + + #[\ReturnTypeWillChange] + public function key() + { + return $this->idx[$this->position]; + } + + #[\ReturnTypeWillChange] + public function next() + { + ++$this->position; + } + + #[\ReturnTypeWillChange] + public function valid() + { + return array_key_exists($this->position, $this->idx); + } + + public function get($key) + { + if (array_key_exists($key, $this->ranges)) { + return $this->ranges[$key]; + } + + return null; + } + + public function getValues() + { + $res = array(); + foreach ($this->ranges as $key => $range) { + $res[$key] = $range->range_value; + } + + return (object) $res; + } + + public function getOriginalValues() + { + $res = array(); + foreach ($this->storedRanges as $key => $range) { + $res[$key] = $range->range_value; + } + + return (object) $res; + } + + public function getRanges() + { + return $this->ranges; + } + + protected function modify($range, $value) + { + $this->ranges[$range]->range_key = $value; + } + + public function set($ranges) + { + foreach ($ranges as $range => $value) { + $this->setRange($range, $value); + } + + $toDelete = array_diff(array_keys($this->ranges), array_keys($ranges)); + foreach ($toDelete as $range) { + $this->remove($range); + } + + return $this; + } + + public function setRange($range, $value) + { + if ($value === null && array_key_exists($range, $this->ranges)) { + $this->remove($range); + return $this; + } + + if (array_key_exists($range, $this->ranges)) { + if ($this->ranges[$range]->range_value === $value) { + return $this; + } else { + $this->ranges[$range]->range_value = $value; + $this->modified = true; + } + } else { + $class = $this->getRangeClass(); + $this->ranges[$range] = $class::create([ + $this->objectIdColumn => $this->object->get('id'), + 'range_key' => $range, + 'range_value' => $value, + ]); + $this->modified = true; + } + + return $this; + } + + /** + * Magic isset check + * + * @return boolean + */ + public function __isset($range) + { + return array_key_exists($range, $this->ranges); + } + + public function remove($range) + { + if (array_key_exists($range, $this->ranges)) { + unset($this->ranges[$range]); + } + + $this->modified = true; + $this->refreshIndex(); + } + + public function clear() + { + $this->ranges = []; + $this->modified = true; + $this->refreshIndex(); + } + + protected function refreshIndex() + { + ksort($this->ranges); + $this->idx = array_keys($this->ranges); + } + + public function listRangesNames() + { + return array_keys($this->ranges); + } + + public function getType() + { + return $this->object->getShortTableName(); + } + + public function getRangeTableName() + { + return $this->object->getTableName() . '_range'; + } + + protected function loadFromDb() + { + $db = $this->object->getDb(); + $connection = $this->object->getConnection(); + + $table = $this->getRangeTableName(); + + $query = $db->select() + ->from(['o' => $table]) + ->where('o.' . $this->objectIdColumn . ' = ?', (int) $this->object->get('id')) + ->order('o.range_key'); + + $class = $this->getRangeClass(); + $this->ranges = $class::loadAll($connection, $query, 'range_key'); + $this->setBeingLoadedFromDb(); + + return $this; + } + + public function setBeingLoadedFromDb() + { + $this->storedRanges = []; + + foreach ($this->ranges as $key => $range) { + $range->setBeingLoadedFromDb(); + $this->storedRanges[$key] = clone($range); + } + $this->refreshIndex(); + $this->modified = false; + } + + public function store() + { + $db = $this->object->getConnection(); + if (! $this->hasBeenModified()) { + return false; + } + + $table = $this->getRangeTableName(); + $objectId = (int) $this->object->get('id'); + $idColumn = $this->objectIdColumn; + foreach ($this->ranges as $range) { + if ($range->hasBeenModified()) { + $range->setConnection($db); + if ((int) $range->get($idColumn) !== $objectId) { + $range->set($idColumn, $objectId); + } + } + } + + foreach (array_diff(array_keys($this->storedRanges), array_keys($this->ranges)) as $delete) { + $range = $this->storedRanges[$delete]; + $range->setConnection($db); + $range->set($idColumn, $objectId); + $db->getDbAdapter()->delete($table, $range->createWhere()); + unset($this->ranges[$delete]); + } + foreach ($this->ranges as $range) { + $range->store(); + } + $this->setBeingLoadedFromDb(); + + return true; + } + + /** + * @return IcingaTimePeriodRange|IcingaScheduledDowntimeRange|string IDE hint + */ + protected function getRangeClass() + { + return $this->rangeClass; + } + + public static function loadForStoredObject(IcingaObject $object) + { + $ranges = new static($object); + return $ranges->loadFromDb(); + } + + public function toConfigString() + { + if (empty($this->ranges) && $this->object->object_type === 'template') { + return ''; + } + + $string = " ranges = {\n"; + + foreach ($this->ranges as $range) { + $string .= sprintf( + " %s\t= %s\n", + c::renderString($range->range_key), + c::renderString($range->range_value) + ); + } + + return $string . " }\n"; + } + + abstract public function toLegacyConfigString(); + + public function __toString() + { + try { + return $this->toConfigString(); + } catch (Exception $e) { + trigger_error($e); + $previousHandler = set_exception_handler( + function () { + } + ); + restore_error_handler(); + if ($previousHandler !== null) { + call_user_func($previousHandler, $e); + die(); + } else { + die($e->getMessage()); + } + } + } +} diff --git a/library/Director/Objects/IcingaRelatedObject.php b/library/Director/Objects/IcingaRelatedObject.php new file mode 100644 index 0000000..d35bcb0 --- /dev/null +++ b/library/Director/Objects/IcingaRelatedObject.php @@ -0,0 +1,211 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Objects\IcingaObject; + +/** + * Related Object + * + * This class comes in handy when working with simple foreign key references. In + * contrast to an ORM it helps to deal with lazy-loaded objects in a way allowing + * us to render objects with references which to no longer (or not yet) exist + */ +class IcingaRelatedObject +{ + /** @var IcingaObject Main object with (optional) relation */ + protected $owner; + + /** @var int Related object id */ + protected $id; + + /** @var int Related object name */ + protected $name; + + /** @var int Relation property name, e.g. 'host' */ + protected $key; + + /** @var int Relation property, e.g. 'host_id' */ + protected $idKey; + + /** @var IcingaObject Related object once loaded */ + protected $object; + + /** @var string Related class name */ + protected $className; + + /** + * IcingaRelatedObject constructor + * + * @param IcingaObject $owner Main object referring a related one + * @param string $key Main objects (short) property name for this + */ + public function __construct(IcingaObject $owner, $key) + { + $this->owner = $owner; + $this->key = $key; + $this->idKey = $key . '_id'; + } + + /** + * Set a specific id + * + * @param $id int + * + * @return self + */ + public function setId($id) + { + if (! is_int($id)) { + throw new ProgrammingError( + 'An id must be an integer' + ); + } + + if ($this->object !== null) { + if ($this->object->id === $id) { + return $this; + } else { + $this->object = null; + } + } + + if ($this->object === null) { + $this->name = null; + } + + $this->id = $id; + $this->owner->set($this->getRealPropertyName(), $id); + + return $this; + } + + /** + * Return the related objects id + * + * @return int + */ + public function getId() + { + if ($this->id === null) { + $this->id = $this->getObject()->id; + } + + return $this->id; + } + + /** + * Lazy-load the related object + * + * @return IcingaObject + */ + public function getObject() + { + // TODO: This is unfinished + + if ($this->object === null) { + $class = $this->getClassName(); + + if ($this->name === null) { + if ($id = $this->getId()) { + } + } else { + $this->object = $class::load($this->name, $this->owner->getConnection()); + } + } + return $this->object; + } + + /** + * The real property name pointing to this relation, e.g. 'host_id' + * + * @return string + */ + public function getRealPropertyName() + { + return $this->key . '_id'; + } + + /** + * Full related class name + * + * @return string + */ + public function getClassName() + { + if ($this->className === null) { + $this->className = __NAMESPACE__ . '\\' . $this->getShortClassName(); + } + + return $this->className; + } + + /** + * Related class name relative to Icinga\Module\Director\Objects + * + * @return string + */ + public function getShortClassName() + { + return $this->owner->getRelationObjectClass($this->key); + } + + /** + * Set a related property + * + * This might be a string or an object + * @param $related string|IcingaObject + * @throws ProgrammingError + * + * return self + */ + public function set($related) + { + if (is_string($related)) { + $this->name = $related; + } elseif (is_object($related)) { + $className = $this->getClassName(); + if ($related instanceof $className) { + $this->object = $related; + $this->name = $object->object_name; + $this->id = $object->id; + } else { + throw new ProgrammingError( + 'Trying to set a related "%s" while expecting "%s"', + get_class($related), + $this->getShortClassName() + ); + } + } else { + throw new ProgrammingError( + 'Related object can be name or object, got: %s', + var_export($related, 1) + ); + } + + return $this; + } + + /** + * Get the name of the related object + * + * @return string + */ + public function getName() + { + if ($this->name === null) { + return $this->owner->{$this->key}; + } else { + return $this->name; + } + } + + /** + * Conservative constructor to avoid issued with PHP GC + */ + public function __destruct() + { + unset($this->owner); + } +} diff --git a/library/Director/Objects/IcingaScheduledDowntime.php b/library/Director/Objects/IcingaScheduledDowntime.php new file mode 100644 index 0000000..7fc3f78 --- /dev/null +++ b/library/Director/Objects/IcingaScheduledDowntime.php @@ -0,0 +1,135 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use RuntimeException; + +class IcingaScheduledDowntime extends IcingaObject +{ + protected $table = 'icinga_scheduled_downtime'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'zone_id' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'author' => null, + 'comment' => null, + 'fixed' => null, + 'duration' => null, + 'apply_to' => null, + 'assign_filter' => null, + 'with_services' => null, + ]; + + protected $uuidColumn = 'uuid'; + + protected $supportsImports = true; + + protected $supportsRanges = true; + + protected $supportsApplyRules = true; + + protected $relations = [ + 'zone' => 'IcingaZone', + ]; + + protected $booleans = [ + 'fixed' => 'fixed', + ]; + + protected $intervalProperties = [ + 'duration' => 'duration', + ]; + + protected $propertiesNotForRendering = [ + 'id', + 'apply_to', + 'object_name', + 'object_type', + 'with_services', + ]; + + /** + * @return string + */ + protected function renderObjectHeader() + { + if ($this->isApplyRule()) { + if (($to = $this->get('apply_to')) === null) { + throw new RuntimeException(sprintf( + 'Applied notification "%s" has no valid object type', + $this->getObjectName() + )); + } + + return sprintf( + "%s %s %s to %s {\n", + $this->getObjectTypeName(), + $this->getType(), + c::renderString($this->getObjectName()), + ucfirst($to) + ); + } else { + return parent::renderObjectHeader(); + } + } + + public function getOnDeleteUrl() + { + if ($this->isApplyRule()) { + return 'director/scheduled-downtimes/applyrules'; + } elseif ($this->isTemplate()) { + return 'director/scheduled-downtimes/templates'; + } else { + return 'director/scheduled-downtimes'; + } + } + + public function isActive($now = null) + { + if ($now === null) { + $now = time(); + } + + foreach ($this->ranges()->getRanges() as $range) { + if ($range->isActive($now)) { + return true; + } + } + + // TODO: no range currently means (and renders) "never", Icinga behaves + // different. Figure out whether and how we should support this + return false; + } + + /** + * @return string + */ + protected function renderSuffix() + { + if ($this->get('with_services') === 'y' && $this->get('apply_to') === 'host') { + return parent::renderSuffix() . $this->renderCloneForServices(); + } else { + return parent::renderSuffix(); + } + } + + protected function prefersGlobalZone() + { + return false; + } + + protected function renderCloneForServices() + { + $services = clone($this); + $services + ->set('with_services', 'n') + ->set('apply_to', 'service'); + + return $services->toConfigString(); + } +} diff --git a/library/Director/Objects/IcingaScheduledDowntimeRange.php b/library/Director/Objects/IcingaScheduledDowntimeRange.php new file mode 100644 index 0000000..6280990 --- /dev/null +++ b/library/Director/Objects/IcingaScheduledDowntimeRange.php @@ -0,0 +1,88 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; + +class IcingaScheduledDowntimeRange extends DbObject +{ + protected $keyName = ['scheduled_downtime_id', 'range_key', 'range_type']; + + protected $table = 'icinga_scheduled_downtime_range'; + + protected $defaultProperties = [ + 'scheduled_downtime_id' => null, + 'range_key' => null, + 'range_value' => null, + 'range_type' => 'include', + 'merge_behaviour' => 'set', + ]; + + public function isActive($now = null) + { + if ($now === null) { + $now = time(); + } + + if (false === ($weekDay = $this->getWeekDay($this->get('range_key')))) { + // TODO, dates are not yet supported + return false; + } + + if ((int) date('w', $now) !== $weekDay) { + return false; + } + + $timeRanges = preg_split('/\s*,\s*/', $this->get('range_value'), -1, PREG_SPLIT_NO_EMPTY); + foreach ($timeRanges as $timeRange) { + if ($this->timeRangeIsActive($timeRange, $now)) { + return true; + } + } + + return false; + } + + protected function timeRangeIsActive($rangeString, $now) + { + $hBegin = $mBegin = $hEnd = $mEnd = null; + if (sscanf($rangeString, '%2d:%2d-%2d:%2d', $hBegin, $mBegin, $hEnd, $mEnd) === 4) { + if ($this->timeFromHourMin($hBegin, $mBegin, $now) <= $now + && $this->timeFromHourMin($hEnd, $mEnd, $now) >= $now + ) { + return true; + } + } else { + // TODO: throw exception? + } + + return false; + } + + protected function timeFromHourMin($hour, $min, $now) + { + return strtotime(sprintf('%s %02d:%02d:00', date('Y-m-d', $now), $hour, $min)); + } + + protected function getWeekDay($day) + { + switch ($day) { + case 'sunday': + return 0; + case 'monday': + return 1; + case 'tuesday': + return 2; + case 'wednesday': + return 3; + case 'thursday': + return 4; + case 'friday': + return 5; + case 'saturday': + return 6; + } + + return false; + } +} diff --git a/library/Director/Objects/IcingaScheduledDowntimeRanges.php b/library/Director/Objects/IcingaScheduledDowntimeRanges.php new file mode 100644 index 0000000..ac8483e --- /dev/null +++ b/library/Director/Objects/IcingaScheduledDowntimeRanges.php @@ -0,0 +1,18 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Countable; +use Iterator; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; + +class IcingaScheduledDowntimeRanges extends IcingaRanges implements Iterator, Countable, IcingaConfigRenderer +{ + protected $rangeClass = IcingaScheduledDowntimeRange::class; + protected $objectIdColumn = 'scheduled_downtime_id'; + + public function toLegacyConfigString() + { + return ''; + } +} diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php new file mode 100644 index 0000000..9479ef7 --- /dev/null +++ b/library/Director/Objects/IcingaService.php @@ -0,0 +1,828 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Data\PropertiesFilter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; +use Icinga\Module\Director\Objects\Extension\FlappingSupport; +use Icinga\Module\Director\Resolver\HostServiceBlacklist; +use InvalidArgumentException; +use RuntimeException; + +class IcingaService extends IcingaObject implements ExportInterface +{ + use FlappingSupport; + + protected $table = 'icinga_service'; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'display_name' => null, + 'host_id' => null, + 'service_set_id' => null, + 'check_command_id' => null, + 'max_check_attempts' => null, + 'check_period_id' => null, + 'check_interval' => null, + 'retry_interval' => null, + 'check_timeout' => null, + 'enable_notifications' => null, + 'enable_active_checks' => null, + 'enable_passive_checks' => null, + 'enable_event_handler' => null, + 'enable_flapping' => null, + 'enable_perfdata' => null, + 'event_command_id' => null, + 'flapping_threshold_high' => null, + 'flapping_threshold_low' => null, + 'volatile' => null, + 'zone_id' => null, + 'command_endpoint_id' => null, + 'notes' => null, + 'notes_url' => null, + 'action_url' => null, + 'icon_image' => null, + 'icon_image_alt' => null, + 'use_agent' => null, + 'apply_for' => null, + 'use_var_overrides' => null, + 'assign_filter' => null, + 'template_choice_id' => null, + ]; + + protected $relations = [ + 'host' => 'IcingaHost', + 'service_set' => 'IcingaServiceSet', + 'check_command' => 'IcingaCommand', + 'event_command' => 'IcingaCommand', + 'check_period' => 'IcingaTimePeriod', + 'command_endpoint' => 'IcingaEndpoint', + 'zone' => 'IcingaZone', + 'template_choice' => 'IcingaTemplateChoiceService', + ]; + + protected $booleans = [ + 'enable_notifications' => 'enable_notifications', + 'enable_active_checks' => 'enable_active_checks', + 'enable_passive_checks' => 'enable_passive_checks', + 'enable_event_handler' => 'enable_event_handler', + 'enable_flapping' => 'enable_flapping', + 'enable_perfdata' => 'enable_perfdata', + 'volatile' => 'volatile', + 'use_agent' => 'use_agent', + 'use_var_overrides' => 'use_var_overrides', + ]; + + protected $intervalProperties = [ + 'check_interval' => 'check_interval', + 'check_timeout' => 'check_timeout', + 'retry_interval' => 'retry_interval', + ]; + + protected $supportsGroups = true; + + protected $supportsCustomVars = true; + + protected $supportsFields = true; + + protected $supportsImports = true; + + protected $supportsApplyRules = true; + + protected $supportsSets = true; + + protected $supportsChoices = true; + + protected $supportedInLegacy = true; + + protected $keyName = ['host_id', 'service_set_id', 'object_name']; + + protected $prioritizedProperties = ['host_id']; + + protected $propertiesNotForRendering = [ + 'id', + 'object_name', + 'object_type', + 'apply_for' + ]; + + /** @var ServiceGroupMembershipResolver */ + protected $servicegroupMembershipResolver; + + /** + * @return IcingaCommand + * @throws IcingaException + * @throws \Icinga\Exception\NotFoundError + */ + public function getCheckCommand() + { + $id = $this->getSingleResolvedProperty('check_command_id'); + return IcingaCommand::loadWithAutoIncId( + $id, + $this->getConnection() + ); + } + + /** + * @return bool + */ + public function isApplyRule() + { + if ($this->hasBeenAssignedToHostTemplate()) { + return true; + } + + return $this->hasProperty('object_type') + && $this->get('object_type') === 'apply'; + } + + /** + * @return bool + */ + public function usesVarOverrides() + { + return $this->get('use_var_overrides') === 'y'; + } + + public function getUniqueIdentifier() + { + if ($this->isTemplate()) { + return $this->getObjectName(); + } else { + throw new RuntimeException( + 'getUniqueIdentifier() is supported by Service Templates only' + ); + } + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + // TODO: ksort in toPlainObject? + $props = (array) $this->toPlainObject(); + $props['fields'] = $this->loadFieldReferences(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaService + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + if ($properties['object_type'] !== 'template') { + throw new InvalidArgumentException(sprintf( + 'Can import only Templates, got "%s" for "%s"', + $properties['object_type'], + $name + )); + } + $key = [ + 'object_type' => 'template', + 'object_name' => $name + ]; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Service Template "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + // $object->newFields = $properties['fields']; + unset($properties['fields']); + $object->setProperties($properties); + + return $object; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return array + */ + protected function loadFieldReferences() + { + $db = $this->getDb(); + + $res = $db->fetchAll( + $db->select()->from([ + 'sf' => 'icinga_service_field' + ], [ + 'sf.datafield_id', + 'sf.is_required', + 'sf.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = sf.datafield_id', []) + ->where('service_id = ?', $this->get('id')) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } else { + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + + return $res; + } + } + + /** + * @param string $key + * @return $this + */ + protected function setKey($key) + { + if (is_int($key)) { + $this->set('id', $key); + } elseif (is_array($key)) { + foreach (['id', 'host_id', 'service_set_id', 'object_name'] as $k) { + if (array_key_exists($k, $key)) { + $this->set($k, $key[$k]); + } + } + } else { + parent::setKey($key); + } + + return $this; + } + + /** + * @param $name + * @return $this + * @codingStandardsIgnoreStart + */ + protected function setObject_Name($name) + { + // @codingStandardsIgnoreEnd + + if ($name === null && $this->isApplyRule()) { + $name = ''; + } + + return $this->reallySet('object_name', $name); + } + + /** + * Render host_id as host_name + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderHost_id() + { + if ($this->hasBeenAssignedToHostTemplate()) { + return ''; + } + + return $this->renderRelationProperty('host', $this->get('host_id'), 'host_name'); + } + + /** + * @codingStandardsIgnoreStart + */ + protected function renderLegacyHost_id($value) + { + // @codingStandardsIgnoreEnd + if (is_array($value)) { + $blacklisted = $this->getBlacklistedHostnames(); + $c = c1::renderKeyValue('host_name', c1::renderArray(array_diff($value, $blacklisted))); + + // blacklisted in this (zoned) scope? + $bl = array_intersect($blacklisted, $value); + if (! empty($bl)) { + $c .= c1::renderKeyValue('# ignored on', c1::renderArray($bl)); + } + + return $c; + } else { + return parent::renderLegacyHost_id($value); + } + } + + /** + * @param IcingaConfig $config + * @throws IcingaException + */ + public function renderToLegacyConfig(IcingaConfig $config) + { + if ($this->get('service_set_id') !== null) { + return; + } elseif ($this->isApplyRule()) { + $this->renderLegacyApplyToConfig($config); + } else { + parent::renderToLegacyConfig($config); + } + } + + /** + * @param IcingaConfig $config + * @throws IcingaException + */ + protected function renderLegacyApplyToConfig(IcingaConfig $config) + { + $conn = $this->getConnection(); + + $assign_filter = $this->get('assign_filter'); + $filter = Filter::fromQueryString($assign_filter); + $hostnames = HostApplyMatches::forFilter($filter, $conn); + + $this->set('object_type', 'object'); + + foreach ($this->mapHostsToZones($hostnames) as $zone => $names) { + $blacklisted = $this->getBlacklistedHostnames(); + $zoneNames = array_diff($names, $blacklisted); + + $disabled = []; + foreach ($zoneNames as $name) { + if (IcingaHost::load($name, $this->getConnection())->isDisabled()) { + $disabled[] = $name; + } + } + $zoneNames = array_diff($zoneNames, $disabled); + + if (empty($zoneNames)) { + continue; + } + + $this->set('host_id', $zoneNames); + + $config->configFile('director/' . $zone . '/service_apply', '.cfg') + ->addLegacyObject($this); + } + } + + /** + * @return string + */ + public function toLegacyConfigString() + { + if ($this->get('service_set_id') !== null) { + return ''; + } + + $str = parent::toLegacyConfigString(); + + if (! $this->isDisabled() + && $this->get('host_id') + && $this->getRelated('host')->isDisabled() + ) { + return "# --- This services host has been disabled ---\n" + . preg_replace('~^~m', '# ', trim($str)) + . "\n\n"; + } else { + return $str; + } + } + + /** + * @return string + */ + public function toConfigString() + { + if ($this->get('service_set_id')) { + return ''; + } + $str = parent::toConfigString(); + + if (! $this->isDisabled() + && $this->get('host_id') + && $this->getRelated('host')->isDisabled() + ) { + return "/* --- This services host has been disabled ---\n" + // Do not allow strings to break our comment + . str_replace('*/', "* /", $str) . "*/\n"; + } else { + return $str; + } + } + + /** + * @return string + */ + protected function renderObjectHeader() + { + if ($this->isApplyRule() + && !$this->hasBeenAssignedToHostTemplate() + && $this->get('apply_for') !== null + ) { + $name = $this->getObjectName(); + $extraName = ''; + + if (c::stringHasMacro($name)) { + $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name)); + $name = ''; + } elseif ($name !== '') { + $name = ' ' . c::renderString($name); + } + + return sprintf( + "%s %s%s for (config in %s) {\n", + $this->getObjectTypeName(), + $this->getType(), + $name, + $this->get('apply_for') + ) . $extraName; + } + + return parent::renderObjectHeader(); + } + + /** + * @return string + */ + protected function getLegacyObjectKeyName() + { + if ($this->isTemplate()) { + return 'name'; + } else { + return 'service_description'; + } + } + + /** + * @return bool + */ + public function hasBeenAssignedToHostTemplate() + { + // Branches would fail + if ($this->properties['host_id'] === null) { + return null; + } + $hostId = $this->get('host_id'); + + return $hostId && $this->getRelatedObject( + 'host', + $hostId + )->isTemplate(); + } + + /** + * @return string + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\NestingError + */ + protected function renderSuffix() + { + $suffix = ''; + if ($this->isApplyRule()) { + $zoneName = $this->getRenderingZone(); + if (!IcingaZone::zoneNameIsGlobal($zoneName, $this->connection)) { + $suffix .= c::renderKeyValue('zone', c::renderString($zoneName)); + } + } + + if ($this->isApplyRule() || $this->usesVarOverrides()) { + $suffix .= $this->renderImportHostVarOverrides(); + } + + return $suffix . parent::renderSuffix(); + } + + /** + * @return string + */ + protected function renderImportHostVarOverrides() + { + if (! $this->connection) { + throw new RuntimeException( + 'Cannot render services without an assigned DB connection' + ); + } + + return "\n import DirectorOverrideTemplate\n"; + } + + /** + * @return string + * @throws \Icinga\Exception\NotFoundError + */ + protected function renderCustomExtensions() + { + $output = ''; + + if ($this->hasBeenAssignedToHostTemplate()) { + // TODO: use assignment renderer? + $filter = sprintf( + 'assign where %s in host.templates', + c::renderString($this->get('host')) + ); + + $output .= "\n " . $filter . "\n"; + } + + $blacklist = $this->getBlacklistedHostnames(); + $blacklistedTemplates = []; + $blacklistedHosts = []; + foreach ($blacklist as $hostname) { + if (IcingaHost::load($hostname, $this->connection)->isTemplate()) { + $blacklistedTemplates[] = $hostname; + } else { + $blacklistedHosts[] = $hostname; + } + } + foreach ($blacklistedTemplates as $template) { + $output .= sprintf( + " ignore where %s in host.templates\n", + c::renderString($template) + ); + } + if (! empty($blacklistedHosts)) { + if (count($blacklistedHosts) === 1) { + $output .= sprintf( + " ignore where host.name == %s\n", + c::renderString($blacklistedHosts[0]) + ); + } else { + $output .= sprintf( + " ignore where host.name in %s\n", + c::renderArray($blacklistedHosts) + ); + } + } + + // A hand-crafted command endpoint overrides use_agent + if ($this->get('command_endpoint_id') !== null) { + return $output; + } + + if ($this->get('use_agent') === 'y') { + // When feature flag feature_custom_endpoint is enabled, render additional code + if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') { + return $output . " + // Set command_endpoint dynamically with Director + if (!host) { + var host = get_host(host_name) + } + if (host.vars._director_custom_endpoint_name) { + command_endpoint = host.vars._director_custom_endpoint_name + } else { + command_endpoint = host_name + } +"; + } else { + return $output . c::renderKeyValue('command_endpoint', 'host_name'); + } + } elseif ($this->get('use_agent') === 'n') { + return $output . c::renderKeyValue('command_endpoint', c::renderPhpValue(null)); + } else { + return $output; + } + } + + /** + * @return array + */ + public function getBlacklistedHostnames() + { + // Hint: if ($this->isApplyRule()) would be nice, but apply rules are + // not enough, one might want to blacklist single services from Sets + // assigned to single Hosts. + if (PrefetchCache::shouldBeUsed()) { + $lookup = PrefetchCache::instance()->hostServiceBlacklist(); + } else { + $lookup = new HostServiceBlacklist($this->getConnection()); + } + + return $lookup->getBlacklistedHostnamesForService($this); + } + + /** + * Do not render internal property + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderUse_agent() + { + return ''; + } + + public function renderUse_var_overrides() + { + return ''; + } + + protected function renderTemplate_choice_id() + { + return ''; + } + + protected function renderLegacyDisplay_Name() + { + // @codingStandardsIgnoreEnd + return c1::renderKeyValue('display_name', $this->get('display_name')); + } + + public function hasCheckCommand() + { + return $this->getSingleResolvedProperty('check_command_id') !== null; + } + + public function getOnDeleteUrl() + { + if ($this->get('host_id')) { + return 'director/host/services?name=' . rawurlencode($this->get('host')); + } elseif ($this->get('service_set_id')) { + return 'director/serviceset/services?name=' . rawurlencode($this->get('service_set')); + } else { + return parent::getOnDeleteUrl(); + } + } + + protected function getDefaultZone(IcingaConfig $config = null) + { + if ($this->get('host_id') === null) { + return parent::getDefaultZone(); + } else { + return $this->getRelatedObject('host', $this->get('host_id')) + ->getRenderingZone($config); + } + } + + /** + * @return string + */ + public function createWhere() + { + $where = parent::createWhere(); + if (! $this->hasBeenLoadedFromDb()) { + if (null === $this->get('service_set_id') + && null === $this->get('host_id') + && null === $this->get('id') + ) { + $where .= " AND object_type = 'template'"; + } + } + + return $where; + } + + + /** + * TODO: Duplicate code, clean this up, split it into multiple methods + * @param Db|null $connection + * @param string $prefix + * @param null $filter + * @return array + */ + public static function enumProperties( + Db $connection = null, + $prefix = '', + $filter = null + ) { + $serviceProperties = []; + if ($filter === null) { + $filter = new PropertiesFilter(); + } + $realProperties = static::create()->listProperties(); + sort($realProperties); + + if ($filter->match(PropertiesFilter::$SERVICE_PROPERTY, 'name')) { + $serviceProperties[$prefix . 'name'] = 'name'; + } + foreach ($realProperties as $prop) { + if (!$filter->match(PropertiesFilter::$SERVICE_PROPERTY, $prop)) { + continue; + } + + if (substr($prop, -3) === '_id') { + if ($prop === 'template_choice_id') { + continue; + } + $prop = substr($prop, 0, -3); + } + + $serviceProperties[$prefix . $prop] = $prop; + } + + $serviceVars = []; + + if ($connection !== null) { + foreach ($connection->fetchDistinctServiceVars() as $var) { + if ($filter->match(PropertiesFilter::$CUSTOM_PROPERTY, $var->varname, $var)) { + if ($var->datatype) { + $serviceVars[$prefix . 'vars.' . $var->varname] = sprintf( + '%s (%s)', + $var->varname, + $var->caption + ); + } else { + $serviceVars[$prefix . 'vars.' . $var->varname] = $var->varname; + } + } + } + } + + //$properties['vars.*'] = 'Other custom variable'; + ksort($serviceVars); + + $props = mt('director', 'Service properties'); + $vars = mt('director', 'Custom variables'); + + $properties = []; + if (!empty($serviceProperties)) { + $properties[$props] = $serviceProperties; + $properties[$props][$prefix . 'groups'] = 'Groups'; + } + + if (!empty($serviceVars)) { + $properties[$vars] = $serviceVars; + } + + $hostProps = mt('director', 'Host properties'); + $hostVars = mt('director', 'Host Custom variables'); + + $hostProperties = IcingaHost::enumProperties($connection, 'host.'); + + if (array_key_exists($hostProps, $hostProperties)) { + $p = $hostProperties[$hostProps]; + if (!empty($p)) { + $properties[$hostProps] = $p; + } + } + + if (array_key_exists($vars, $hostProperties)) { + $p = $hostProperties[$vars]; + if (!empty($p)) { + $properties[$hostVars] = $p; + } + } + + return $properties; + } + + protected function beforeStore() + { + parent::beforeStore(); + if ($this->isObject() + && $this->get('service_set_id') === null + && $this->get('host_id') === null + ) { + throw new InvalidArgumentException( + 'Cannot store a Service object without a related host or set: ' . $this->getObjectName() + ); + } + } + + protected function notifyResolvers() + { + $resolver = $this->getServiceGroupMembershipResolver(); + $resolver->addObject($this); + $resolver->refreshDb(); + + return $this; + } + + protected function getServiceGroupMembershipResolver() + { + if ($this->servicegroupMembershipResolver === null) { + $this->servicegroupMembershipResolver = new ServiceGroupMembershipResolver( + $this->getConnection() + ); + } + + return $this->servicegroupMembershipResolver; + } + + public function setServiceGroupMembershipResolver(ServiceGroupMembershipResolver $resolver) + { + $this->servicegroupMembershipResolver = $resolver; + return $this; + } +} diff --git a/library/Director/Objects/IcingaServiceAssignment.php b/library/Director/Objects/IcingaServiceAssignment.php new file mode 100644 index 0000000..9910f7c --- /dev/null +++ b/library/Director/Objects/IcingaServiceAssignment.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaServiceAssignment extends IcingaObject +{ + protected $table = 'icinga_service_assignment'; + + protected $keyName = 'id'; + + protected $defaultProperties = array( + 'id' => null, + 'service_id' => null, + 'filter_string' => null, + ); + + protected $relations = array( + 'service' => 'IcingaService', + ); +} diff --git a/library/Director/Objects/IcingaServiceField.php b/library/Director/Objects/IcingaServiceField.php new file mode 100644 index 0000000..c43ec2d --- /dev/null +++ b/library/Director/Objects/IcingaServiceField.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaServiceField extends IcingaObjectField +{ + protected $keyName = array('service_id', 'datafield_id'); + + protected $table = 'icinga_service_field'; + + protected $defaultProperties = array( + 'service_id' => null, + 'datafield_id' => null, + 'is_required' => null, + 'var_filter' => null, + ); +} diff --git a/library/Director/Objects/IcingaServiceGroup.php b/library/Director/Objects/IcingaServiceGroup.php new file mode 100644 index 0000000..ae43ff3 --- /dev/null +++ b/library/Director/Objects/IcingaServiceGroup.php @@ -0,0 +1,42 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaServiceGroup extends IcingaObjectGroup +{ + protected $table = 'icinga_servicegroup'; + + /** @var ServiceGroupMembershipResolver */ + protected $servicegroupMembershipResolver; + + public function supportsAssignments() + { + return true; + } + + protected function getServiceGroupMembershipResolver() + { + if ($this->servicegroupMembershipResolver === null) { + $this->servicegroupMembershipResolver = new ServiceGroupMembershipResolver( + $this->getConnection() + ); + } + + return $this->servicegroupMembershipResolver; + } + + public function setServiceGroupMembershipResolver(ServiceGroupMembershipResolver $resolver) + { + $this->servicegroupMembershipResolver = $resolver; + return $this; + } + + protected function notifyResolvers() + { + $resolver = $this->getServiceGroupMembershipResolver(); + $resolver->addGroup($this); + $resolver->refreshDb(); + + return $this; + } +} diff --git a/library/Director/Objects/IcingaServiceSet.php b/library/Director/Objects/IcingaServiceSet.php new file mode 100644 index 0000000..8217a59 --- /dev/null +++ b/library/Director/Objects/IcingaServiceSet.php @@ -0,0 +1,591 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\Db\ServiceSetQueryBuilder; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Resolver\HostServiceBlacklist; +use InvalidArgumentException; +use Ramsey\Uuid\Uuid; +use RuntimeException; + +class IcingaServiceSet extends IcingaObject implements ExportInterface +{ + protected $table = 'icinga_service_set'; + + protected $defaultProperties = array( + 'id' => null, + 'uuid' => null, + 'host_id' => null, + 'object_name' => null, + 'object_type' => null, + 'description' => null, + 'assign_filter' => null, + ); + + protected $uuidColumn = 'uuid'; + + protected $keyName = array('host_id', 'object_name'); + + protected $supportsImports = true; + + protected $supportsCustomVars = true; + + protected $supportsApplyRules = true; + + protected $supportedInLegacy = true; + + protected $relations = array( + 'host' => 'IcingaHost', + ); + + public function isDisabled() + { + return false; + } + + public function supportsAssignments() + { + return true; + } + + protected function setKey($key) + { + if (is_int($key)) { + $this->set('id', $key); + } elseif (is_string($key)) { + $keyComponents = preg_split('~!~', $key); + if (count($keyComponents) === 1) { + $this->set('object_name', $keyComponents[0]); + $this->set('object_type', 'template'); + } else { + throw new InvalidArgumentException(sprintf( + 'Can not parse key: %s', + $key + )); + } + } else { + return parent::setKey($key); + } + + return $this; + } + + /** + * @return IcingaService[] + * @throws \Icinga\Exception\NotFoundError + */ + public function getServiceObjects() + { + // don't try to resolve services for unstored objects - as in getServiceObjectsForSet() + // also for diff in activity log + if ($this->get('id') === null) { + return []; + } + + if ($this->get('host_id')) { + $imports = $this->imports()->getObjects(); + if (empty($imports)) { + return array(); + } + $parent = array_shift($imports); + assert($parent instanceof IcingaServiceSet); + return $this->getServiceObjectsForSet($parent); + } else { + return $this->getServiceObjectsForSet($this); + } + } + + /** + * @param IcingaServiceSet $set + * @return IcingaService[] + * @throws \Icinga\Exception\NotFoundError + */ + protected function getServiceObjectsForSet(IcingaServiceSet $set) + { + $connection = $this->getConnection(); + if (self::$dbObjectStore !== null) { + $branchUuid = self::$dbObjectStore->getBranch()->getUuid(); + } else { + $branchUuid = null; + } + + $builder = new ServiceSetQueryBuilder($connection, $branchUuid); + return $builder->fetchServicesWithQuery($builder->selectServicesForSet($set)); + } + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @return object + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + if ($this->get('host_id')) { + $result = $this->exportSetOnHost(); + } else { + $result = $this->exportTemplate(); + } + + unset($result->uuid); + return $result; + } + + protected function exportSetOnHost() + { + // TODO. + throw new RuntimeException('Not yet'); + } + + /** + * @return object + * @deprecated + * @throws \Icinga\Exception\NotFoundError + */ + protected function exportTemplate() + { + $props = $this->getProperties(); + unset($props['id'], $props['host_id']); + $props['services'] = []; + foreach ($this->getServiceObjects() as $serviceObject) { + $props['services'][$serviceObject->getObjectName()] = $serviceObject->export(); + } + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaServiceSet + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + if (isset($properties['services'])) { + $services = $properties['services']; + unset($properties['services']); + } else { + $services = []; + } + + if ($properties['object_type'] !== 'template') { + throw new InvalidArgumentException(sprintf( + 'Can import only Templates, got "%s" for "%s"', + $properties['object_type'], + $name + )); + } + if ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::exists($name, $db)) { + throw new DuplicateKeyException( + 'Service Set "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $object->setProperties($properties); + + // This is not how other imports work, but here we need an ID + if (! $object->hasBeenLoadedFromDb()) { + $object->store(); + } + + $setId = $object->get('id'); + $sQuery = $db->getDbAdapter()->select()->from( + ['s' => 'icinga_service'], + 's.*' + )->where('service_set_id = ?', $setId); + $existingServices = IcingaService::loadAll($db, $sQuery, 'object_name'); + $serviceNames = []; + foreach ($services as $service) { + if (isset($service->fields)) { + unset($service->fields); + } + $name = $service->object_name; + $serviceNames[] = $name; + if (isset($existingServices[$name])) { + $existing = $existingServices[$name]; + $existing->setProperties((array) $service); + $existing->set('service_set_id', $setId); + if ($existing->hasBeenModified()) { + $existing->store(); + } + } else { + $new = IcingaService::create((array) $service, $db); + $new->set('service_set_id', $setId); + $new->store(); + } + } + + foreach ($existingServices as $existing) { + if (!in_array($existing->getObjectName(), $serviceNames)) { + $existing->delete(); + } + } + + return $object; + } + + public function beforeDelete() + { + // check if this is a template, or directly assigned to a host + if ($this->get('host_id') === null) { + // find all host sets and delete them + foreach ($this->fetchHostSets() as $set) { + $set->delete(); + } + } + + parent::beforeDelete(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + */ + public function onDelete() + { + $hostId = $this->get('host_id'); + if ($hostId) { + $deleteIds = []; + foreach ($this->getServiceObjects() as $service) { + if ($idToDelete = $service->get('id')) { + $deleteIds[] = (int) $idToDelete; + } + } + + if (! empty($deleteIds)) { + $db = $this->getDb(); + $db->delete( + 'icinga_host_service_blacklist', + $db->quoteInto( + sprintf('host_id = %s AND service_id IN (?)', $hostId), + $deleteIds + ) + ); + } + } + + parent::onDelete(); + } + + /** + * @param IcingaConfig $config + * @throws \Icinga\Exception\NotFoundError + */ + public function renderToConfig(IcingaConfig $config) + { + // always print the header, so you have minimal info present + $file = $this->getConfigFileWithHeader($config); + + if ($this->get('assign_filter') === null && $this->isTemplate()) { + return; + } + + if ($config->isLegacy()) { + $this->renderToLegacyConfig($config); + return; + } + + $services = $this->getServiceObjects(); + if (empty($services)) { + return; + } + + // Loop over all services belonging to this set + // add our assign rules and then add the service to the config + // eventually clone them beforehand to not get into trouble with caches + // figure out whether we might need a zone property + foreach ($services as $service) { + if ($filter = $this->get('assign_filter')) { + $service->set('object_type', 'apply'); + $service->set('assign_filter', $filter); + } elseif ($hostId = $this->get('host_id')) { + $host = $this->getRelatedObject('host', $hostId)->getObjectName(); + if (in_array($host, $this->getBlacklistedHostnames($service))) { + continue; + } + $service->set('object_type', 'object'); + $service->set('use_var_overrides', 'y'); + $service->set('host_id', $hostId); + } else { + // Service set template without assign filter or host + continue; + } + + $this->copyVarsToService($service); + $file->addObject($service); + } + } + + /** + * @return array + */ + public function getBlacklistedHostnames($service) + { + // Hint: if ($this->isApplyRule()) would be nice, but apply rules are + // not enough, one might want to blacklist single services from Sets + // assigned to single Hosts. + if (PrefetchCache::shouldBeUsed()) { + $lookup = PrefetchCache::instance()->hostServiceBlacklist(); + } else { + $lookup = new HostServiceBlacklist($this->getConnection()); + } + + return $lookup->getBlacklistedHostnamesForService($service); + } + + protected function getConfigFileWithHeader(IcingaConfig $config) + { + $file = $config->configFile( + 'zones.d/' . $this->getRenderingZone($config) . '/servicesets' + ); + + $file->addContent($this->getConfigHeaderComment($config)); + return $file; + } + + protected function getConfigHeaderComment(IcingaConfig $config) + { + $name = $this->getObjectName(); + $assign = $this->get('assign_filter'); + + if ($config->isLegacy()) { + if ($assign !== null) { + return "## applied Service Set '${name}'\n\n"; + } else { + return "## Service Set '${name}' on this host\n\n"; + } + } else { + $comment = [ + "Service Set: ${name}", + ]; + + if (($host = $this->get('host')) !== null) { + $comment[] = 'on host ' . $host; + } + + if (($description = $this->get('description')) !== null) { + $comment[] = ''; + foreach (preg_split('~\\n~', $description) as $line) { + $comment[] = $line; + } + } + + if ($assign !== null) { + $comment[] = ''; + $comment[] = trim($this->renderAssign_Filter()); + } + + return "/**\n * " . join("\n * ", $comment) . "\n */\n\n"; + } + } + + public function copyVarsToService(IcingaService $service) + { + $serviceVars = $service->vars(); + + foreach ($this->vars() as $k => $var) { + $serviceVars->$k = $var; + } + + return $this; + } + + /** + * @param IcingaConfig $config + * @throws \Icinga\Exception\NotFoundError + */ + public function renderToLegacyConfig(IcingaConfig $config) + { + if ($this->get('assign_filter') === null && $this->isTemplate()) { + return; + } + + // evaluate my assign rules once, get related hosts + // Loop over all services belonging to this set + // generate every service with host_name host1,host2... -> not yet. And Zones? + + $conn = $this->getConnection(); + + // Delegating this to the service would look, but this way it's faster + if ($filter = $this->get('assign_filter')) { + $filter = Filter::fromQueryString($filter); + + $hostnames = HostApplyMatches::forFilter($filter, $conn); + } else { + $hostnames = array($this->getRelated('host')->getObjectName()); + } + + $blacklists = []; + + foreach ($this->mapHostsToZones($hostnames) as $zone => $names) { + $file = $config->configFile('director/' . $zone . '/servicesets', '.cfg'); + $file->addContent($this->getConfigHeaderComment($config)); + + foreach ($this->getServiceObjects() as $service) { + $object_name = $service->getObjectName(); + + if (! array_key_exists($object_name, $blacklists)) { + $blacklists[$object_name] = $service->getBlacklistedHostnames(); + } + + // check if all hosts in the zone ignore this service + $zoneNames = array_diff($names, $blacklists[$object_name]); + + $disabled = []; + foreach ($zoneNames as $name) { + if (IcingaHost::load($name, $this->getConnection())->isDisabled()) { + $disabled[] = $name; + } + } + $zoneNames = array_diff($zoneNames, $disabled); + + if (empty($zoneNames)) { + continue; + } + + $service->set('object_type', 'object'); + $service->set('host_id', $names); + + $this->copyVarsToService($service); + + $file->addLegacyObject($service); + } + } + } + + public function getRenderingZone(IcingaConfig $config = null) + { + if ($this->get('host_id') === null) { + if ($hostname = $this->get('host')) { + $host = IcingaHost::load($hostname, $this->getConnection()); + } else { + return $this->connection->getDefaultGlobalZoneName(); + } + } else { + $host = $this->getRelatedObject('host', $this->get('host_id')); + } + + return $host->getRenderingZone($config); + } + + public function createWhere() + { + $where = parent::createWhere(); + if (! $this->hasBeenLoadedFromDb()) { + if (null === $this->get('host_id') && null === $this->get('id')) { + $where .= " AND object_type = 'template'"; + } + } + + return $where; + } + + /** + * @return IcingaService[] + */ + public function fetchServices() + { + if ($store = self::$dbObjectStore) { + $uuid = $store->getBranch()->getUuid(); + } else { + $uuid = null; + } + $builder = new ServiceSetQueryBuilder($this->getConnection(), $uuid); + return $builder->fetchServicesWithQuery($builder->selectServicesForSet($this)); + } + + /** + * Fetch IcingaServiceSet that are based on this set and added to hosts directly + * + * @return IcingaServiceSet[] + */ + public function fetchHostSets() + { + $id = $this->get('id'); + if ($id === null) { + return []; + } + + $query = $this->db->select() + ->from( + ['o' => $this->table] + )->join( + ['ssi' => $this->table . '_inheritance'], + 'ssi.service_set_id = o.id', + [] + )->where( + 'ssi.parent_service_set_id = ?', + $id + ); + + return static::loadAll($this->connection, $query); + } + + /** + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + protected function beforeStore() + { + parent::beforeStore(); + + $name = $this->getObjectName(); + + if ($this->isObject() && $this->get('host_id') === null && $this->get('host') === null) { + throw new InvalidArgumentException( + 'A Service Set cannot be an object with no related host' + ); + } + // checking if template object_name is unique + // TODO: Move to IcingaObject + if (! $this->hasBeenLoadedFromDb() && $this->isTemplate() && static::exists($name, $this->connection)) { + throw new DuplicateKeyException( + '%s template "%s" already existing in database!', + $this->getType(), + $name + ); + } + } + + public function toSingleIcingaConfig() + { + $config = parent::toSingleIcingaConfig(); + + try { + foreach ($this->fetchHostSets() as $set) { + $set->renderToConfig($config); + } + } catch (Exception $e) { + $config->configFile( + 'failed-to-render' + )->prepend( + "/** Failed to render this Service Set **/\n" + . '/* ' . $e->getMessage() . ' */' + ); + } + + return $config; + } +} diff --git a/library/Director/Objects/IcingaServiceSetAssignment.php b/library/Director/Objects/IcingaServiceSetAssignment.php new file mode 100644 index 0000000..4a6ebbc --- /dev/null +++ b/library/Director/Objects/IcingaServiceSetAssignment.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaServiceSetAssignment extends IcingaObject +{ + protected $table = 'icinga_service_set_assignment'; + + protected $keyName = 'id'; + + protected $defaultProperties = array( + 'id' => null, + 'service_set_id' => null, + 'filter_string' => null, + ); + + protected $relations = array( + 'service_set' => 'IcingaServiceSet', + ); +} diff --git a/library/Director/Objects/IcingaServiceVar.php b/library/Director/Objects/IcingaServiceVar.php new file mode 100644 index 0000000..0b855b2 --- /dev/null +++ b/library/Director/Objects/IcingaServiceVar.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaServiceVar extends IcingaObject +{ + protected $keyName = array('service_id', 'varname'); + + protected $table = 'icinga_service_var'; + + protected $defaultProperties = array( + 'service_id' => null, + 'varname' => null, + 'varvalue' => null, + 'format' => null, + ); + + public function onInsert() + { + } + + public function onUpdate() + { + } + + public function onDelete() + { + } +} diff --git a/library/Director/Objects/IcingaTemplateChoice.php b/library/Director/Objects/IcingaTemplateChoice.php new file mode 100644 index 0000000..1a1be90 --- /dev/null +++ b/library/Director/Objects/IcingaTemplateChoice.php @@ -0,0 +1,321 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Web\Form\QuickForm; + +class IcingaTemplateChoice extends IcingaObject implements ExportInterface +{ + protected $objectTable; + + protected $defaultProperties = [ + 'id' => null, + 'object_name' => null, + 'description' => null, + 'min_required' => 0, + 'max_allowed' => 1, + 'required_template_id' => null, + 'allowed_roles' => null, + ]; + + private $choices; + + private $newChoices; + + public function getObjectShortTableName() + { + return substr(substr($this->table, 0, -16), 7); + } + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaTemplateChoice + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + if (isset($properties['originalId'])) { + unset($properties['originalId']); + } + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Template Choice "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $object->setProperties($properties); + + return $object; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return array|object|\stdClass + */ + public function export() + { + $plain = (object) $this->getProperties(); + $plain->originalId = $plain->id; + unset($plain->id); + $requiredId = $plain->required_template_id; + unset($plain->required_template_id); + if ($requiredId) { + $db = $this->getDb(); + $query = $db->select() + ->from(['o' => $this->getObjectTableName()], 'o.object_name')->where("o.object_type = 'template'") + ->where('o.id = ?', $this->get('id')); + $plain->required_template = $db->fetchOne($query); + } + + $plain->members = array_values($this->getMembers()); + + return $plain; + } + + public function isMainChoice() + { + return $this->hasBeenLoadedFromDb() + && $this->connection->settings()->get('main_host_choice'); + } + + public function getObjectTableName() + { + return substr($this->table, 0, -16); + } + + /** + * @param QuickForm $form + * @param array $imports + * @param string $namePrefix + * @return \Zend_Form_Element + * @throws \Zend_Form_Exception + */ + public function createFormElement(QuickForm $form, $imports = [], $namePrefix = 'choice') + { + $required = $this->isRequired() && !$this->isTemplate(); + $type = $this->allowsMultipleChoices() ? 'multiselect' : 'select'; + $choices = $this->enumChoices(); + + $chosen = []; + foreach ($imports as $import) { + if (array_key_exists($import, $choices)) { + $chosen[] = $import; + } + } + + $attributes = [ + 'label' => $this->getObjectName(), + 'description' => $this->get('description'), + 'required' => $required, + 'ignore' => true, + 'value' => $chosen, + 'multiOptions' => $form->optionalEnum($choices), + 'class' => 'autosubmit' + ]; + + // unused + if ($type === 'extensibleSet') { + $attributes['sorted'] = true; + } + + $key = $namePrefix . $this->get('id'); + return $form->createElement($type, $key, $attributes); + } + + public function isRequired() + { + return (int) $this->get('min_required') > 0; + } + + public function allowsMultipleChoices() + { + return (int) $this->get('max_allowed') > 1; + } + + public function hasBeenModified() + { + if ($this->newChoices !== null && $this->choices !== $this->newChoices) { + return true; + } + + return parent::hasBeenModified(); + } + + public function getMembers() + { + return $this->enumChoices(); + } + + public function setMembers($members) + { + if (empty($members)) { + $this->newChoices = array(); + return $this; + } + $db = $this->getDb(); + $query = $db->select()->from( + ['o' => $this->getObjectTableName()], + ['o.id', 'o.object_name'] + )->where("o.object_type = 'template'") + ->where('o.object_name IN (?)', $members) + ->order('o.object_name'); + + $this->newChoices = $db->fetchPairs($query); + return $this; + } + + public function getChoices() + { + if ($this->newChoices !== null) { + return $this->newChoices; + } + + if ($this->choices === null) { + $this->choices = $this->fetchChoices(); + } + + return $this->choices; + } + + public function fetchChoices() + { + if ($this->hasBeenLoadedFromDb()) { + $db = $this->getDb(); + $query = $db->select()->from( + ['o' => $this->getObjectTableName()], + ['o.id', 'o.object_name'] + )->where("o.object_type = 'template'") + ->where('o.template_choice_id = ?', $this->get('id')) + ->order('o.object_name'); + + return $db->fetchPairs($query); + } else { + return []; + } + } + + public function enumChoices() + { + $choices = $this->getChoices(); + return array_combine($choices, $choices); + } + + /** + * @throws \Zend_Db_Adapter_Exception + */ + public function onStore() + { + parent::onStore(); + if ($this->newChoices !== $this->choices) { + $this->storeChoices(); + } + } + + /** + * @throws \Zend_Db_Adapter_Exception + */ + protected function storeChoices() + { + $id = $this->getProperty('id'); + $db = $this->getDb(); + $ids = array_keys($this->newChoices); + $table = $this->getObjectTableName(); + + if (empty($ids)) { + $db->update( + $table, + ['template_choice_id' => null], + $db->quoteInto( + sprintf('template_choice_id = %d', $id), + $ids + ) + ); + } else { + $db->update( + $table, + ['template_choice_id' => null], + $db->quoteInto( + sprintf('template_choice_id = %d AND id NOT IN (?)', $id), + $ids + ) + ); + $db->update( + $table, + ['template_choice_id' => $id], + $db->quoteInto('id IN (?)', $ids) + ); + } + } + + /** + * @param $roles + * @throws ProgrammingError + * @codingStandardsIgnoreStart + */ + public function setAllowed_roles($roles) + { + // @codingStandardsIgnoreEnd + $key = 'allowed_roles'; + if (is_array($roles)) { + $this->reallySet($key, json_encode($roles)); + } elseif (null === $roles) { + $this->reallySet($key, null); + } else { + throw new ProgrammingError( + 'Expected array or null for allowed_roles, got %s', + var_export($roles, 1) + ); + } + } + + /** + * @return array|null + * @codingStandardsIgnoreStart + */ + public function getAllowed_roles() + { + // @codingStandardsIgnoreEnd + + // Might be removed once all choice types have allowed_roles + if (! array_key_exists('allowed_roles', $this->properties)) { + return null; + } + + $roles = $this->getProperty('allowed_roles'); + if (is_string($roles)) { + return json_decode($roles); + } else { + return $roles; + } + } + + /** + * @param $type + * @codingStandardsIgnoreStart + */ + public function setObject_type($type) + { + // @codingStandardsIgnoreEnd + } +} diff --git a/library/Director/Objects/IcingaTemplateChoiceHost.php b/library/Director/Objects/IcingaTemplateChoiceHost.php new file mode 100644 index 0000000..10ddedd --- /dev/null +++ b/library/Director/Objects/IcingaTemplateChoiceHost.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaTemplateChoiceHost extends IcingaTemplateChoice +{ + protected $table = 'icinga_host_template_choice'; + + protected $objectTable = 'icinga_host'; + + protected $relations = array( + 'required_template' => 'IcingaHost', + ); +} diff --git a/library/Director/Objects/IcingaTemplateChoiceService.php b/library/Director/Objects/IcingaTemplateChoiceService.php new file mode 100644 index 0000000..5cdb43e --- /dev/null +++ b/library/Director/Objects/IcingaTemplateChoiceService.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaTemplateChoiceService extends IcingaTemplateChoice +{ + protected $table = 'icinga_service_template_choice'; + + protected $objectTable = 'icinga_service'; + + protected $relations = array( + 'required_template' => 'IcingaService', + ); +} diff --git a/library/Director/Objects/IcingaTemplateResolver.php b/library/Director/Objects/IcingaTemplateResolver.php new file mode 100644 index 0000000..61122a0 --- /dev/null +++ b/library/Director/Objects/IcingaTemplateResolver.php @@ -0,0 +1,479 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Application\Benchmark; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Exception\NestingError; + +// TODO: move the 'type' layer to another class +class IcingaTemplateResolver +{ + /** @var IcingaObject */ + protected $object; + + /** @var Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + protected $type; + + protected static $templates = array(); + + protected static $idIdx = array(); + + protected static $reverseIdIdx = array(); + + protected static $nameIdx = array(); + + protected static $idToName = array(); + + protected static $nameToId = array(); + + public function __construct(IcingaObject $object) + { + $this->setObject($object); + } + + /** + * Set a specific object for this resolver instance + */ + public function setObject(IcingaObject $object) + { + $this->object = $object; + $this->type = $object->getShortTableName(); + $this->table = $object->getTableName(); + $this->connection = $object->getConnection(); + $this->db = $this->connection->getDbAdapter(); + + return $this; + } + + /** + * Forget all template relation of the given object type + * + * @return self + */ + public function clearCache() + { + unset(self::$templates[$this->type]); + return $this; + } + + /** + * Fetch direct parents + * + * return IcingaObject[] + */ + public function fetchParents() + { + // TODO: involve lookup cache + $res = array(); + $class = $this->object; + foreach ($this->listParentIds() as $id) { + $object = $class::loadWithAutoIncId($id, $this->connection); + $res[$object->object_name] = $object; + } + + return $res; + } + + public function listParentIds($id = null) + { + $this->requireTemplates(); + + if ($id === null) { + $object = $this->object; + + if ($object->hasBeenLoadedFromDb()) { + if ($object->gotImports() && $object->imports()->hasBeenModified()) { + return $this->listUnstoredParentIds(); + } + + $id = $object->id; + } else { + return $this->listUnstoredParentIds(); + } + } + + $type = $this->type; + + if (array_key_exists($id, self::$idIdx[$type])) { + return array_keys(self::$idIdx[$type][$id]); + } + + return array(); + } + + protected function listUnstoredParentIds() + { + return $this->getIdsForNames($this->listUnstoredParentNames()); + } + + protected function listUnstoredParentNames() + { + return $this->object->imports()->listImportNames(); + } + + public function listParentNames($name = null) + { + $this->requireTemplates(); + + if ($name === null) { + $object = $this->object; + + if ($object->hasBeenLoadedFromDb()) { + if ($object->gotImports() && $object->imports()->hasBeenModified()) { + return $this->listUnstoredParentNames(); + } + + $name = $object->object_name; + } else { + return $this->listUnstoredParentNames(); + } + } + + $type = $this->type; + + if (array_key_exists($name, self::$nameIdx[$type])) { + return array_keys(self::$nameIdx[$type][$name]); + } + + return array(); + } + + public function fetchResolvedParents() + { + if ($this->object->hasBeenLoadedFromDb()) { + return $this->fetchObjectsById($this->listResolvedParentIds()); + } + + $objects = array(); + foreach ($this->object->imports()->getObjects() as $parent) { + $objects += $parent->templateResolver()->fetchResolvedParents(); + } + + return $objects; + } + + public function listResolvedParentIds() + { + $this->requireTemplates(); + return $this->resolveParentIds(); + } + + /** + * TODO: unfinished and not used currently + * + * @return array + */ + public function listResolvedParentNames() + { + $this->requireTemplates(); + if (array_key_exists($name, self::$nameIdx[$type])) { + return array_keys(self::$nameIdx[$type][$name]); + } + + return $this->resolveParentNames($this->object->object_name); + } + + public function listParentsById($id) + { + return $this->getNamesForIds($this->resolveParentIds($id)); + } + + public function listParentsByName($name) + { + return $this->resolveParentNames($name); + } + + /** + * Gives a list of all object ids met when walking through ancestry + * + * Tree is walked in import order, duplicates are preserved, the given + * objectId is added last + * + * @param int $objectId + * + * @return array + */ + public function listFullInheritancePathIds($objectId = null) + { + $parentIds = $this->listParentIds($objectId); + $ids = array(); + + foreach ($parentIds as $parentId) { + foreach ($this->listFullInheritancePathIds($parentId) as $id) { + $ids[] = $id; + } + + $ids[] = $parentId; + } + + $object = $this->object; + if ($objectId === null && $object->hasBeenLoadedFromDb()) { + $ids[] = $object->id; + } + + return $ids; + } + + public function listChildren($objectId = null) + { + if ($objectId === null) { + $objectId = $this->object->id; + } + + if (array_key_exists($objectId, self::$reverseIdIdx[$this->type])) { + return self::$reverseIdIdx[$this->type][$objectId]; + } else { + return array(); + } + } + + public function listChildIds($objectId = null) + { + return array_keys($this->listChildren($objectId)); + } + + public function listDescendantIds($objectId = null) + { + if ($objectId === null) { + $objectId = $this->object->id; + } + } + + public function listInheritancePathIds($objectId = null) + { + return $this->uniquePathIds($this->listFullInheritancePathIds($objectId)); + } + + public function uniquePathIds(array $ids) + { + $single = array(); + foreach (array_reverse($ids) as $id) { + if (array_key_exists($id, $single)) { + continue; + } + $single[$id] = $id; + } + + return array_reverse(array_keys($single)); + } + + protected function resolveParentNames($name, &$list = array(), $path = array()) + { + $this->assertNotInList($name, $path); + $path[$name] = true; + foreach ($this->listParentNames($name) as $parent) { + $list[$parent] = true; + $this->resolveParentNames($parent, $list, $path); + unset($list[$parent]); + $list[$parent] = true; + } + + return array_keys($list); + } + + protected function resolveParentIds($id = null, &$list = array(), $path = array()) + { + if ($id === null) { + if ($check = $this->object->id) { + $this->assertNotInList($check, $path); + $path[$check] = true; + } + } else { + $this->assertNotInList($id, $path); + $path[$id] = true; + } + + foreach ($this->listParentIds($id) as $parent) { + $list[$parent] = true; + $this->resolveParentIds($parent, $list, $path); + unset($list[$parent]); + $list[$parent] = true; + } + + return array_keys($list); + } + + protected function assertNotInList($id, &$list) + { + if (array_key_exists($id, $list)) { + $list = array_keys($list); + $list[] = $id; + if (is_numeric($id)) { + throw new NestingError( + 'Loop detected: %s', + implode(' -> ', $this->getNamesForIds($list)) + ); + } else { + throw new NestingError( + 'Loop detected: %s', + implode(' -> ', $list) + ); + } + } + } + + protected function getNamesForIds($ids) + { + $names = array(); + foreach ($ids as $id) { + $names[] = $this->getNameForId($id); + } + + return $names; + } + + protected function getNameForId($id) + { + return self::$idToName[$this->type][$id]; + } + + protected function getIdsForNames($names) + { + $this->requireTemplates(); + $ids = array(); + foreach ($names as $name) { + $ids[] = $this->getIdForName($name); + } + + return $ids; + } + + protected function getIdForName($name) + { + if (! array_key_exists($name, self::$nameToId[$this->type])) { + throw new NotFoundError('There is no such import: "%s"', $name); + } + + return self::$nameToId[$this->type][$name]; + } + + protected function fetchObjectsById($ids) + { + $class = $this->object; + $connection = $this->connection; + $res = array(); + + foreach ($ids as $id) { + $res[] = $class::loadWithAutoIncId($id, $connection); + } + + return $res; + } + + protected function requireTemplates() + { + if (! array_key_exists($this->type, self::$templates)) { + $this->prepareLookupTables(); + } + + return $this; + } + + protected function prepareLookupTables() + { + $type = $this->type; + + Benchmark::measure("Preparing '$type' TemplateResolver lookup tables"); + $templates = $this->fetchTemplates(); + + $ids = array(); + $reverseIds = array(); + $names = array(); + $idToName = array(); + $nameToId = array(); + + foreach ($templates as $row) { + $id = $row->id; + $idToName[$id] = $row->name; + $nameToId[$row->name] = $id; + + if ($row->parent_id === null) { + continue; + } + $parentId = $row->parent_id; + $parentName = $row->parent_name; + + if (array_key_exists($id, $ids)) { + $ids[$id][$parentId] = $parentName; + $names[$row->name][$parentName] = $row->parent_id; + } else { + $ids[$id] = array( + $parentId => $parentName + ); + + $names[$row->name] = array( + $parentName => $parentId + ); + } + + if (! array_key_exists($parentId, $reverseIds)) { + $reverseIds[$parentId] = array(); + } + $reverseIds[$parentId][$id] = $row->name; + } + + self::$idIdx[$type] = $ids; + self::$reverseIdIdx[$type] = $reverseIds; + self::$nameIdx[$type] = $names; + self::$templates[$type] = $templates; // TODO: this is unused, isn't it? + self::$idToName[$type] = $idToName; + self::$nameToId[$type] = $nameToId; + Benchmark::measure('Preparing TemplateResolver lookup tables'); + } + + protected function fetchTemplates() + { + $db = $this->db; + $type = $this->type; + $table = $this->object->getTableName(); + + $query = $db->select()->from( + array('o' => $table), + array( + 'id' => 'o.id', + 'name' => 'o.object_name', + 'parent_id' => 'p.id', + 'parent_name' => 'p.object_name', + ) + )->joinLeft( + array('i' => $table . '_inheritance'), + 'o.id = i.' . $type . '_id', + array() + )->joinLeft( + array('p' => $table), + 'p.id = i.parent_' . $type . '_id', + array() + )->order('o.id')->order('i.weight'); + + return $db->fetchAll($query); + } + + public function __destruct() + { + unset($this->connection); + unset($this->db); + unset($this->object); + } + + public function refreshObject(IcingaObject $object) + { + $type = $object->getShortTableName(); + $name = $object->getObjectName(); + $parentNames = $object->imports; + self::$nameIdx[$type][$name] = $parentNames; + if ($object->hasBeenLoadedFromDb()) { + $id = $object->getProperty('id'); + self::$idIdx[$type][$id] = $this->getIdsForNames($parentNames); + self::$idToName[$type][$id] = $name; + self::$nameToId[$type][$name] = $id; + } + return $this; + } +} diff --git a/library/Director/Objects/IcingaTimePeriod.php b/library/Director/Objects/IcingaTimePeriod.php new file mode 100644 index 0000000..1232366 --- /dev/null +++ b/library/Director/Objects/IcingaTimePeriod.php @@ -0,0 +1,190 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +class IcingaTimePeriod extends IcingaObject implements ExportInterface +{ + protected $table = 'icinga_timeperiod'; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'zone_id' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'prefer_includes' => null, + 'display_name' => null, + 'update_method' => null, + ]; + + protected $booleans = [ + 'prefer_includes' => 'prefer_includes', + ]; + + protected $supportsImports = true; + + protected $supportsRanges = true; + + protected $supportedInLegacy = true; + + protected $relations = array( + 'zone' => 'IcingaZone', + ); + + protected $multiRelations = [ + 'includes' => [ + 'relatedObjectClass' => 'IcingaTimeperiod', + 'relatedShortName' => 'include', + ], + 'excludes' => [ + 'relatedObjectClass' => 'IcingaTimeperiod', + 'relatedShortName' => 'exclude', + 'legacyPropertyName' => 'exclude' + ], + ]; + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return object + * @throws \Icinga\Exception\NotFoundError + */ + public function export() + { + $props = (array) $this->toPlainObject(); + ksort($props); + + return (object) $props; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $name = $properties['object_name']; + $key = $name; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Time Period "%s" already exists', + $name + ); + } else { + $object = static::create([], $db); + } + $object->setProperties($properties); + + return $object; + } + + /** + * Render update property + * + * Avoid complaints for method names with underscore: + * @codingStandardsIgnoreStart + * + * @return string + */ + public function renderUpdate_method() + { + // @codingStandardsIgnoreEnd + return ''; + } + + protected function renderObjectHeader() + { + return parent::renderObjectHeader() + . ' import "legacy-timeperiod"' . "\n"; + } + + protected function checkPeriodInRange($now, $name = null) + { + if ($name !== null) { + $period = static::load($name, $this->connection); + } else { + $period = $this; + } + + foreach ($period->ranges()->getRanges() as $range) { + if ($range->isActive($now)) { + return true; + } + } + + return false; + } + + public function isActive($now = null) + { + if ($now === null) { + $now = time(); + } + + $preferIncludes = $this->get('prefer_includes') !== 'n'; + + $active = $this->checkPeriodInRange($now); + $included = false; + $excluded = false; + + $variants = [ + 'includes' => &$included, + 'excludes' => &$excluded + ]; + + foreach ($variants as $key => &$var) { + foreach ($this->get($key) as $name) { + if ($this->checkPeriodInRange($now, $name)) { + $var = true; + break; + } + } + } + + if ($preferIncludes) { + if ($included) { + return true; + } elseif ($excluded) { + return false; + } else { + return $active; + } + } else { + if ($excluded) { + return false; + } elseif ($included) { + return true; + } else { + return $active; + } + } + + // TODO: no range currently means (and renders) "never", Icinga behaves + // different. Figure out whether and how we should support this + return false; + } + + protected function prefersGlobalZone() + { + return true; + } +} diff --git a/library/Director/Objects/IcingaTimePeriodRange.php b/library/Director/Objects/IcingaTimePeriodRange.php new file mode 100644 index 0000000..55c1a3e --- /dev/null +++ b/library/Director/Objects/IcingaTimePeriodRange.php @@ -0,0 +1,88 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; + +class IcingaTimePeriodRange extends DbObject +{ + protected $keyName = array('timeperiod_id', 'range_key', 'range_type'); + + protected $table = 'icinga_timeperiod_range'; + + protected $defaultProperties = array( + 'timeperiod_id' => null, + 'range_key' => null, + 'range_value' => null, + 'range_type' => 'include', + 'merge_behaviour' => 'set', + ); + + public function isActive($now = null) + { + if ($now === null) { + $now = time(); + } + + if (false === ($weekDay = $this->getWeekDay($this->get('range_key')))) { + // TODO, dates are not yet supported + return false; + } + + if ((int) date('w', $now) !== $weekDay) { + return false; + } + + $timeRanges = preg_split('/\s*,\s*/', $this->get('range_value'), -1, PREG_SPLIT_NO_EMPTY); + foreach ($timeRanges as $timeRange) { + if ($this->timeRangeIsActive($timeRange, $now)) { + return true; + } + } + + return false; + } + + protected function timeRangeIsActive($rangeString, $now) + { + $hBegin = $mBegin = $hEnd = $mEnd = null; + if (sscanf($rangeString, '%2d:%2d-%2d:%2d', $hBegin, $mBegin, $hEnd, $mEnd) === 4) { + if ($this->timeFromHourMin($hBegin, $mBegin, $now) <= $now + && $this->timeFromHourMin($hEnd, $mEnd, $now) >= $now + ) { + return true; + } + } else { + // TODO: throw exception? + } + + return false; + } + + protected function timeFromHourMin($hour, $min, $now) + { + return strtotime(sprintf('%s %02d:%02d:00', date('Y-m-d', $now), $hour, $min)); + } + + protected function getWeekDay($day) + { + switch ($day) { + case 'sunday': + return 0; + case 'monday': + return 1; + case 'tuesday': + return 2; + case 'wednesday': + return 3; + case 'thursday': + return 4; + case 'friday': + return 5; + case 'saturday': + return 6; + } + + return false; + } +} diff --git a/library/Director/Objects/IcingaTimePeriodRanges.php b/library/Director/Objects/IcingaTimePeriodRanges.php new file mode 100644 index 0000000..b18437d --- /dev/null +++ b/library/Director/Objects/IcingaTimePeriodRanges.php @@ -0,0 +1,35 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Countable; +use Iterator; +use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer; +use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1; + +class IcingaTimePeriodRanges extends IcingaRanges implements Iterator, Countable, IcingaConfigRenderer +{ + protected $rangeClass = IcingaTimePeriodRange::class; + protected $objectIdColumn = 'timeperiod_id'; + + public function toLegacyConfigString() + { + if (empty($this->ranges) && $this->object->isTemplate()) { + return ''; + } + + $out = ''; + + foreach ($this->ranges as $range) { + $out .= c1::renderKeyValue( + $range->get('range_key'), + $range->get('range_value') + ); + } + if ($out !== '') { + $out = "\n".$out; + } + + return $out; + } +} diff --git a/library/Director/Objects/IcingaUser.php b/library/Director/Objects/IcingaUser.php new file mode 100644 index 0000000..394e849 --- /dev/null +++ b/library/Director/Objects/IcingaUser.php @@ -0,0 +1,92 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; + +class IcingaUser extends IcingaObject implements ExportInterface +{ + protected $table = 'icinga_user'; + + protected $defaultProperties = array( + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'display_name' => null, + 'email' => null, + 'pager' => null, + 'enable_notifications' => null, + 'period_id' => null, + 'zone_id' => null, + ); + + protected $uuidColumn = 'uuid'; + + protected $supportsGroups = true; + + protected $supportsCustomVars = true; + + protected $supportsFields = true; + + protected $supportsImports = true; + + protected $booleans = array( + 'enable_notifications' => 'enable_notifications' + ); + + protected $relatedSets = array( + 'states' => 'StateFilterSet', + 'types' => 'TypeFilterSet', + ); + + protected $relations = array( + 'period' => 'IcingaTimePeriod', + 'zone' => 'IcingaZone', + ); + + public function export() + { + return ImportExportHelper::simpleExport($this); + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return IcingaUser + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + $key = $properties['object_name']; + + if ($replace && static::exists($key, $db)) { + $object = static::load($key, $db); + } elseif (static::exists($key, $db)) { + throw new DuplicateKeyException( + 'Cannot import, %s "%s" already exists', + static::create([])->getShortTableName(), + $key + ); + } else { + $object = static::create([], $db); + } + + // $object->newFields = $properties['fields']; + unset($properties['fields']); + $object->setProperties($properties); + + return $object; + } + + public function getUniqueIdentifier() + { + return $this->getObjectName(); + } +} diff --git a/library/Director/Objects/IcingaUserField.php b/library/Director/Objects/IcingaUserField.php new file mode 100644 index 0000000..4a6432c --- /dev/null +++ b/library/Director/Objects/IcingaUserField.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaUserField extends IcingaObjectField +{ + protected $keyName = array('user_id', 'datafield_id'); + + protected $table = 'icinga_user_field'; + + protected $defaultProperties = array( + 'user_id' => null, + 'datafield_id' => null, + 'is_required' => null, + 'var_filter' => null, + ); +} diff --git a/library/Director/Objects/IcingaUserGroup.php b/library/Director/Objects/IcingaUserGroup.php new file mode 100644 index 0000000..656235a --- /dev/null +++ b/library/Director/Objects/IcingaUserGroup.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class IcingaUserGroup extends IcingaObjectGroup +{ + protected $table = 'icinga_usergroup'; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'display_name' => null, + 'zone_id' => null, + ]; + + protected $relations = [ + 'zone' => 'IcingaZone', + ]; + + protected function prefersGlobalZone() + { + return false; + } +} diff --git a/library/Director/Objects/IcingaVar.php b/library/Director/Objects/IcingaVar.php new file mode 100644 index 0000000..10addf2 --- /dev/null +++ b/library/Director/Objects/IcingaVar.php @@ -0,0 +1,72 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\CustomVariable\CustomVariable; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; + +class IcingaVar extends DbObject +{ + protected $table = 'icinga_var'; + + protected $keyName = 'checksum'; + + /** @var CustomVariable */ + protected $var; + + protected $defaultProperties = [ + 'checksum' => null, + 'rendered_checksum' => null, + 'varname' => null, + 'varvalue' => null, + 'rendered' => null + ]; + + protected $binaryProperties = [ + 'checksum', + 'rendered_checksum', + ]; + + /** + * @param CustomVariable $customVar + * @param Db $db + * + * @return static + */ + public static function forCustomVar(CustomVariable $customVar, Db $db) + { + $rendered = $customVar->render(); + + $var = static::create(array( + 'checksum' => $customVar->checksum(), + 'rendered_checksum' => sha1($rendered, true), + 'varname' => $customVar->getKey(), + 'varvalue' => $customVar->toJson(), + 'rendered' => $rendered, + ), $db); + + $var->var = $customVar; + + return $var; + } + + /** + * @param CustomVariable $customVar + * @param Db $db + * + * @return static + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public static function generateForCustomVar(CustomVariable $customVar, Db $db) + { + $var = static::forCustomVar($customVar, $db); + $var->store(); + return $var; + } + + protected function onInsert() + { + IcingaFlatVar::generateForCustomVar($this->var, $this->getConnection()); + } +} diff --git a/library/Director/Objects/IcingaZone.php b/library/Director/Objects/IcingaZone.php new file mode 100644 index 0000000..8d77e47 --- /dev/null +++ b/library/Director/Objects/IcingaZone.php @@ -0,0 +1,110 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c; + +class IcingaZone extends IcingaObject +{ + protected $table = 'icinga_zone'; + + protected $uuidColumn = 'uuid'; + + protected $defaultProperties = [ + 'id' => null, + 'uuid' => null, + 'object_name' => null, + 'object_type' => null, + 'disabled' => 'n', + 'parent_id' => null, + 'is_global' => 'n', + ]; + + protected $booleans = [ + // Global is a reserved word in SQL, column name was prefixed + 'is_global' => 'global' + ]; + + protected $relations = [ + 'parent' => 'IcingaZone', + ]; + + protected $supportsImports = true; + + protected static $globalZoneNames; + + private $endpointList; + + protected function renderCustomExtensions() + { + $endpoints = $this->listEndpoints(); + if (empty($endpoints)) { + return ''; + } + + return c::renderKeyValue('endpoints', c::renderArray($endpoints)); + } + + public function isGlobal() + { + return $this->get('is_global') === 'y'; + } + + public static function zoneNameIsGlobal($name, Db $connection) + { + if (self::$globalZoneNames === null) { + $db = $connection->getDbAdapter(); + self::setCachedGlobalZoneNames($db->fetchCol( + $db->select()->from('icinga_zone', 'object_name')->where('is_global = ?', 'y') + )); + } + + return \in_array($name, self::$globalZoneNames); + } + + public static function setCachedGlobalZoneNames($names) + { + self::$globalZoneNames = $names; + } + + public function getRenderingZone(IcingaConfig $config = null) + { + // If the zone has a parent zone... + if ($this->get('parent_id')) { + // ...we render the zone object to the parent zone + return $this->get('parent'); + } elseif ($this->get('is_global') === 'y') { + // ...additional global zones are rendered to our global zone... + return $this->connection->getDefaultGlobalZoneName(); + } else { + // ...and all the other zones are rendered to our master zone + return $this->connection->getMasterZoneName(); + } + } + + public function setEndpointList($list) + { + $this->endpointList = $list; + + return $this; + } + + // TODO: Move this away, should be prefetchable: + public function listEndpoints() + { + $id = $this->get('id'); + if ($id && $this->endpointList === null) { + $db = $this->getDb(); + $query = $db->select() + ->from('icinga_endpoint', 'object_name') + ->where('zone_id = ?', $id) + ->order('object_name'); + + $this->endpointList = $db->fetchCol($query); + } + + return $this->endpointList; + } +} diff --git a/library/Director/Objects/ImportExportHelper.php b/library/Director/Objects/ImportExportHelper.php new file mode 100644 index 0000000..98d34c6 --- /dev/null +++ b/library/Director/Objects/ImportExportHelper.php @@ -0,0 +1,68 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Db; + +/** + * Helper class, allows to reduce duplicate code. Might be moved elsewhere + * afterwards + */ +class ImportExportHelper +{ + /** + * Does not support every type out of the box + * + * @param IcingaObject $object + * @return object + * @throws \Icinga\Exception\NotFoundError + */ + public static function simpleExport(IcingaObject $object) + { + $props = (array) $object->toPlainObject(); + $props['fields'] = static::fetchFields($object); + ksort($props); // TODO: ksort in toPlainObject? + + return (object) $props; + } + + public static function fetchFields(IcingaObject $object) + { + return static::loadFieldReferences( + $object->getConnection(), + $object->getShortTableName(), + $object->get('id') + ); + } + + /** + * @param Db $connection + * @param string $type Warning: this will not be validated. + * @param int $id + * @return array + */ + public static function loadFieldReferences(Db $connection, $type, $id) + { + $db = $connection->getDbAdapter(); + $res = $db->fetchAll( + $db->select()->from([ + 'f' => "icinga_${type}_field" + ], [ + 'f.datafield_id', + 'f.is_required', + 'f.var_filter', + ])->join(['df' => 'director_datafield'], 'df.id = f.datafield_id', []) + ->where("${type}_id = ?", $id) + ->order('varname ASC') + ); + + if (empty($res)) { + return []; + } + + foreach ($res as $field) { + $field->datafield_id = (int) $field->datafield_id; + } + return $res; + } +} diff --git a/library/Director/Objects/ImportRowModifier.php b/library/Director/Objects/ImportRowModifier.php new file mode 100644 index 0000000..76982c2 --- /dev/null +++ b/library/Director/Objects/ImportRowModifier.php @@ -0,0 +1,91 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Objects\Extension\PriorityColumn; +use RuntimeException; + +class ImportRowModifier extends DbObjectWithSettings implements InstantiatedViaHook +{ + use PriorityColumn; + + protected $table = 'import_row_modifier'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = [ + 'id' => null, + 'source_id' => null, + 'property_name' => null, + 'provider_class' => null, + 'target_property' => null, + 'priority' => null, + 'description' => null, + ]; + + protected $settingsTable = 'import_row_modifier_setting'; + + protected $settingsRemoteId = 'row_modifier_id'; + + private $hookInstance; + + public function getInstance() + { + if ($this->hookInstance === null) { + $class = $this->get('provider_class'); + /** @var PropertyModifierHook $obj */ + if (! class_exists($class)) { + throw new RuntimeException(sprintf( + 'Cannot instantiate Property modifier %s', + $class + )); + } + $obj = new $class; + $obj->setSettings($this->getSettings()); + $obj->setPropertyName($this->get('property_name')); + $obj->setTargetProperty($this->get('target_property')); + $obj->setDb($this->connection); + $this->hookInstance = $obj; + } + + return $this->hookInstance; + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return \stdClass + */ + public function export() + { + $properties = $this->getProperties(); + unset($properties['id']); + unset($properties['source_id']); + $properties['settings'] = $this->getInstance()->exportSettings(); + ksort($properties); + + return (object) $properties; + } + + public function setSettings($settings) + { + $settings = $this->getInstance()->setSettings((array) $settings)->getSettings(); + + return parent::setSettings($settings); // TODO: Change the autogenerated stub + } + + protected function beforeStore() + { + if (! $this->hasBeenLoadedFromDb() && $this->get('priority') === null) { + $this->setNextPriority('source_id'); + } + } + + protected function onInsert() + { + $this->refreshPriortyProperty(); + } +} diff --git a/library/Director/Objects/ImportRun.php b/library/Director/Objects/ImportRun.php new file mode 100644 index 0000000..d3bdb7c --- /dev/null +++ b/library/Director/Objects/ImportRun.php @@ -0,0 +1,159 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; + +class ImportRun extends DbObject +{ + protected $table = 'import_run'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + /** @var ImportSource */ + protected $importSource = null; + + protected $defaultProperties = [ + 'id' => null, + 'source_id' => null, + 'rowset_checksum' => null, + 'start_time' => null, + 'end_time' => null, + // TODO: Check whether succeeded could be dropped + 'succeeded' => null, + ]; + + protected $binaryProperties = [ + 'rowset_checksum', + ]; + + public function prepareImportedObjectQuery($columns = array('object_name')) + { + return $this->getDb()->select()->from( + array('r' => 'imported_row'), + $columns + )->joinLeft( + array('rsr' => 'imported_rowset_row'), + 'rsr.row_checksum = r.checksum', + array() + )->where( + 'rsr.rowset_checksum = ?', + $this->getConnection()->quoteBinary($this->rowset_checksum) + ); + } + + public function listColumnNames() + { + $db = $this->getDb(); + + $query = $db->select()->distinct()->from( + array('p' => 'imported_property'), + 'property_name' + )->join( + array('rp' => 'imported_row_property'), + 'rp.property_checksum = p.checksum', + array() + )->join( + array('rsr' => 'imported_rowset_row'), + 'rsr.row_checksum = rp.row_checksum', + array() + )->where('rsr.rowset_checksum = ?', $this->getConnection()->quoteBinary($this->rowset_checksum)); + + return $db->fetchCol($query); + } + + public function fetchRows($columns, $filter = null, $keys = null) + { + $db = $this->getDb(); + /** @var Db $connection */ + $connection = $this->getConnection(); + $binchecksum = $this->rowset_checksum; + + $query = $db->select()->from( + array('rsr' => 'imported_rowset_row'), + array( + 'object_name' => 'r.object_name', + 'property_name' => 'p.property_name', + 'property_value' => 'p.property_value', + 'format' => 'p.format' + ) + )->join( + array('r' => 'imported_row'), + 'rsr.row_checksum = r.checksum', + array() + )->join( + array('rp' => 'imported_row_property'), + 'r.checksum = rp.row_checksum', + array() + )->join( + array('p' => 'imported_property'), + 'p.checksum = rp.property_checksum', + array() + )->order('r.object_name'); + if ($connection->isMysql()) { + $query->where('rsr.rowset_checksum = :checksum')->bind([ + 'checksum' => $binchecksum + ]); + } else { + $query->where( + 'rsr.rowset_checksum = ?', + $connection->quoteBinary($binchecksum) + ); + } + + if ($columns === null) { + $columns = $this->listColumnNames(); + } else { + $query->where('p.property_name IN (?)', $columns); + } + + $result = array(); + $empty = (object) array(); + foreach ($columns as $k => $v) { + $empty->$k = null; + } + + if ($keys !== null) { + $query->where('r.object_name IN (?)', $keys); + } + + foreach ($db->fetchAll($query) as $row) { + if (! array_key_exists($row->object_name, $result)) { + $result[$row->object_name] = clone($empty); + } + + if ($row->format === 'json') { + $result[$row->object_name]->{$row->property_name} = json_decode($row->property_value); + } else { + $result[$row->object_name]->{$row->property_name} = $row->property_value; + } + } + + if ($filter) { + $filtered = array(); + foreach ($result as $key => $row) { + if ($filter->matches($row)) { + $filtered[$key] = $row; + } + } + + return $filtered; + } + + return $result; + } + + public function importSource() + { + if ($this->importSource === null) { + $this->importSource = ImportSource::loadWithAutoIncId( + (int) $this->get('source_id'), + $this->connection + ); + } + return $this->importSource; + } +} diff --git a/library/Director/Objects/ImportSource.php b/library/Director/Objects/ImportSource.php new file mode 100644 index 0000000..fd892ef --- /dev/null +++ b/library/Director/Objects/ImportSource.php @@ -0,0 +1,537 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Application\Benchmark; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Import\Import; +use Icinga\Module\Director\Import\SyncUtils; +use InvalidArgumentException; +use Exception; + +class ImportSource extends DbObjectWithSettings implements ExportInterface +{ + protected $table = 'import_source'; + + protected $keyName = 'source_name'; + + protected $autoincKeyName = 'id'; + + protected $protectAutoinc = false; + + protected $defaultProperties = [ + 'id' => null, + 'source_name' => null, + 'provider_class' => null, + 'key_column' => null, + 'import_state' => 'unknown', + 'last_error_message' => null, + 'last_attempt' => null, + 'description' => null, + ]; + + protected $stateProperties = [ + 'import_state', + 'last_error_message', + 'last_attempt', + ]; + + protected $settingsTable = 'import_source_setting'; + + protected $settingsRemoteId = 'source_id'; + + private $rowModifiers; + + private $loadedRowModifiers; + + private $newRowModifiers; + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return \stdClass + */ + public function export() + { + $plain = $this->getProperties(); + $plain['originalId'] = $plain['id']; + unset($plain['id']); + + foreach ($this->stateProperties as $key) { + unset($plain[$key]); + } + + $plain['settings'] = (object) $this->getSettings(); + $plain['modifiers'] = $this->exportRowModifiers(); + ksort($plain); + + return (object) $plain; + } + + /** + * @param $plain + * @param Db $db + * @param bool $replace + * @return ImportSource + * @throws DuplicateKeyException + * @throws NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + if (isset($properties['originalId'])) { + $id = $properties['originalId']; + unset($properties['originalId']); + } else { + $id = null; + } + $name = $properties['source_name']; + + if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) { + $object = static::loadWithAutoIncId($id, $db); + } elseif ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::existsWithName($name, $db)) { + throw new DuplicateKeyException( + 'Import Source %s already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + if (! isset($properties['modifiers'])) { + $properties['modifiers'] = []; + } + + $object->setProperties($properties); + + return $object; + } + + public function setModifiers(array $modifiers) + { + if ($this->loadedRowModifiers === null && $this->hasBeenLoadedFromDb()) { + $this->loadedRowModifiers = $this->fetchRowModifiers(); + } + $current = (array) $this->loadedRowModifiers; + if (\count($current) !== \count($modifiers)) { + $this->newRowModifiers = $modifiers; + } else { + $i = 0; + $modified = false; + foreach ($modifiers as $props) { + $this->loadedRowModifiers[$i]->setProperties((array) $props); + if ($this->loadedRowModifiers[$i]->hasBeenModified()) { + $modified = true; + } + $i++; + } + if ($modified) { + $this->newRowModifiers = $modifiers; + } + } + } + + public function hasBeenModified() + { + return $this->newRowModifiers !== null + || parent::hasBeenModified(); + } + + public function getUniqueIdentifier() + { + return $this->get('source_name'); + } + + /** + * @param $name + * @param Db $connection + * @return ImportSource + * @throws NotFoundError + */ + public static function loadByName($name, Db $connection) + { + $db = $connection->getDbAdapter(); + $properties = $db->fetchRow( + $db->select()->from('import_source')->where('source_name = ?', $name) + ); + if ($properties === false) { + throw new NotFoundError(sprintf( + 'There is no such Import Source: "%s"', + $name + )); + } + + return static::create([], $connection)->setDbProperties($properties); + } + + public static function existsWithName($name, Db $connection) + { + $db = $connection->getDbAdapter(); + + return (string) $name === (string) $db->fetchOne( + $db->select() + ->from('import_source', 'source_name') + ->where('source_name = ?', $name) + ); + } + + /** + * @param string $name + * @param int $id + * @param Db $connection + * @api internal + * @return bool + */ + protected static function existsWithNameAndId($name, $id, Db $connection) + { + $db = $connection->getDbAdapter(); + $dummy = new static; + $idCol = $dummy->autoincKeyName; + $keyCol = $dummy->keyName; + + return (string) $id === (string) $db->fetchOne( + $db->select() + ->from($dummy->table, $idCol) + ->where("$idCol = ?", $id) + ->where("$keyCol = ?", $name) + ); + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader + * @return array + */ + protected function exportRowModifiers() + { + $modifiers = []; + foreach ($this->fetchRowModifiers() as $modifier) { + $modifiers[] = $modifier->export(); + } + + return $modifiers; + } + + /** + * @param bool $required + * @return ImportRun|null + * @throws NotFoundError + */ + public function fetchLastRun($required = false) + { + return $this->fetchLastRunBefore(time() + 1, $required); + } + + /** + * @throws DuplicateKeyException + */ + protected function onStore() + { + parent::onStore(); + if ($this->newRowModifiers !== null) { + $connection = $this->getConnection(); + $db = $connection->getDbAdapter(); + $myId = $this->get('id'); + if ($this->hasBeenLoadedFromDb()) { + $db->delete( + 'import_row_modifier', + $db->quoteInto('source_id = ?', $myId) + ); + } + + foreach ($this->newRowModifiers as $modifier) { + $modifier = ImportRowModifier::create((array) $modifier, $connection); + $modifier->set('source_id', $myId); + $modifier->store(); + } + } + } + + /** + * @param $timestamp + * @param bool $required + * @return ImportRun|null + * @throws NotFoundError + */ + public function fetchLastRunBefore($timestamp, $required = false) + { + if (! $this->hasBeenLoadedFromDb()) { + return $this->nullUnlessRequired($required); + } + + if ($timestamp === null) { + $timestamp = time(); + } + + $db = $this->getDb(); + $query = $db->select()->from( + ['ir' => 'import_run'], + 'ir.id' + )->where('ir.source_id = ?', $this->get('id')) + ->where('ir.start_time < ?', date('Y-m-d H:i:s', $timestamp)) + ->order('ir.start_time DESC') + ->limit(1); + + $runId = $db->fetchOne($query); + + if ($runId) { + return ImportRun::load($runId, $this->getConnection()); + } else { + return $this->nullUnlessRequired($required); + } + } + + /** + * @param $required + * @return null + * @throws NotFoundError + */ + protected function nullUnlessRequired($required) + { + if ($required) { + throw new NotFoundError( + 'No data has been imported for "%s" yet', + $this->get('source_name') + ); + } + + return null; + } + + public function applyModifiers(&$data) + { + $modifiers = $this->fetchFlatRowModifiers(); + + if (empty($modifiers)) { + return $this; + } + + foreach ($modifiers as $modPair) { + /** @var PropertyModifierHook $modifier */ + list($property, $modifier) = $modPair; + $rejected = []; + $newRows = []; + foreach ($data as $key => $row) { + $this->applyPropertyModifierToRow($modifier, $property, $row); + if ($modifier->rejectsRow()) { + $rejected[] = $key; + $modifier->rejectRow(false); + } + if ($modifier->expandsRows()) { + $target = $modifier->getTargetProperty($property); + + $newValue = $row->$target; + if (\is_array($newValue)) { + foreach ($newValue as $val) { + $newRow = clone $row; + $newRow->$target = $val; + $newRows[] = $newRow; + } + $rejected[] = $key; + } + } + } + + foreach ($rejected as $key) { + unset($data[$key]); + } + foreach ($newRows as $row) { + $data[] = $row; + } + } + + return $this; + } + + public function getObjectName() + { + return $this->get('source_name'); + } + + public static function getKeyColumnName() + { + return 'source_name'; + } + + protected function applyPropertyModifierToRow(PropertyModifierHook $modifier, $key, $row) + { + if (! is_object($row)) { + throw new InvalidArgumentException('Every imported row MUST be an object'); + } + if ($modifier->requiresRow()) { + $modifier->setRow($row); + } + + if (property_exists($row, $key)) { + $value = $row->$key; + } elseif (strpos($key, '.') !== false) { + $value = SyncUtils::getSpecificValue($row, $key); + } else { + $value = null; + } + + $target = $modifier->getTargetProperty($key); + if (strpos($target, '.') !== false) { + throw new InvalidArgumentException(sprintf( + 'Cannot set value for nested key "%s"', + $target + )); + } + + if (is_array($value) && ! $modifier->hasArraySupport()) { + $new = []; + foreach ($value as $k => $v) { + $new[$k] = $modifier->transform($v); + } + $row->$target = $new; + } else { + $row->$target = $modifier->transform($value); + } + } + + public function getRowModifiers() + { + if ($this->rowModifiers === null) { + $this->prepareRowModifiers(); + } + + return $this->rowModifiers; + } + + public function hasRowModifiers() + { + return count($this->getRowModifiers()) > 0; + } + + /** + * @return ImportRowModifier[] + */ + public function fetchRowModifiers() + { + $db = $this->getDb(); + $modifiers = ImportRowModifier::loadAll( + $this->getConnection(), + $db->select() + ->from('import_row_modifier') + ->where('source_id = ?', $this->get('id')) + ->order('priority ASC') + ); + + if ($modifiers) { + return $modifiers; + } else { + return []; + } + } + + protected function fetchFlatRowModifiers() + { + $mods = []; + foreach ($this->fetchRowModifiers() as $mod) { + $mods[] = [$mod->get('property_name'), $mod->getInstance()]; + } + + return $mods; + } + + protected function prepareRowModifiers() + { + $modifiers = []; + + foreach ($this->fetchRowModifiers() as $mod) { + $name = $mod->get('property_name'); + if (! array_key_exists($name, $modifiers)) { + $modifiers[$name] = []; + } + + $modifiers[$name][] = $mod->getInstance(); + } + + $this->rowModifiers = $modifiers; + } + + public function listModifierTargetProperties() + { + $list = []; + foreach ($this->getRowModifiers() as $rowMods) { + /** @var PropertyModifierHook $mod */ + foreach ($rowMods as $mod) { + if ($mod->hasTargetProperty()) { + $list[$mod->getTargetProperty()] = true; + } + } + } + + return array_keys($list); + } + + /** + * @param bool $runImport + * @return bool + * @throws DuplicateKeyException + */ + public function checkForChanges($runImport = false) + { + $hadChanges = false; + + $name = $this->get('source_name'); + Benchmark::measure("Starting with import $name"); + $this->raiseLimits(); + try { + $import = new Import($this); + $this->set('last_attempt', date('Y-m-d H:i:s')); + if ($import->providesChanges()) { + Benchmark::measure("Found changes for $name"); + $hadChanges = true; + $this->set('import_state', 'pending-changes'); + + if ($runImport && $import->run()) { + Benchmark::measure("Import succeeded for $name"); + $this->set('import_state', 'in-sync'); + } + } else { + $this->set('import_state', 'in-sync'); + } + + $this->set('last_error_message', null); + } catch (Exception $e) { + $this->set('import_state', 'failing'); + Benchmark::measure("Import failed for $name"); + $this->set('last_error_message', $e->getMessage()); + } + + if ($this->hasBeenModified()) { + $this->store(); + } + + return $hadChanges; + } + + /** + * @return bool + * @throws DuplicateKeyException + */ + public function runImport() + { + return $this->checkForChanges(true); + } + + /** + * Raise PHP resource limits + * + * @return $this; + */ + protected function raiseLimits() + { + MemoryLimit::raiseTo('1024M'); + ini_set('max_execution_time', 0); + + return $this; + } +} diff --git a/library/Director/Objects/InstantiatedViaHook.php b/library/Director/Objects/InstantiatedViaHook.php new file mode 100644 index 0000000..79f3442 --- /dev/null +++ b/library/Director/Objects/InstantiatedViaHook.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Hook\JobHook; +use Icinga\Module\Director\Hook\PropertyModifierHook; + +interface InstantiatedViaHook +{ + /** + * @return mixed|PropertyModifierHook|JobHook + */ + public function getInstance(); +} diff --git a/library/Director/Objects/ObjectApplyMatches.php b/library/Director/Objects/ObjectApplyMatches.php new file mode 100644 index 0000000..018c880 --- /dev/null +++ b/library/Director/Objects/ObjectApplyMatches.php @@ -0,0 +1,239 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Application\MemoryLimit; +use Icinga\Module\Director\Data\AssignFilterHelper; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use stdClass; + +abstract class ObjectApplyMatches +{ + protected static $flatObjects; + + protected static $columnMap = array( + 'name' => 'object_name' + ); + + protected $object; + + protected $flatObject; + + protected static $type; + + protected static $preparedFilters = array(); + + public static function prepare(IcingaObject $object) + { + return new static($object); + } + + /** + * Prepare a Filter with fixed columns, and store the result + * + * @param Filter $filter + * + * @return Filter + */ + protected static function getPreparedFilter(Filter $filter) + { + $hash = spl_object_hash($filter); + if (! array_key_exists($hash, self::$preparedFilters)) { + $filter = clone($filter); + static::fixFilterColumns($filter); + self::$preparedFilters[$hash] = $filter; + } + return self::$preparedFilters[$hash]; + } + + public function matchesFilter(Filter $filter) + { + $filterObj = static::getPreparedFilter($filter); + if ($filterObj->isExpression() || ! $filterObj->isEmpty()) { + return AssignFilterHelper::matchesFilter($filterObj, $this->flatObject); + } else { + return false; + } + } + + /** + * @param Filter $filter + * @param Db $db + * + * @return array + */ + public static function forFilter(Filter $filter, Db $db) + { + $result = array(); + Benchmark::measure(sprintf('Starting Filter %s', $filter)); + $filter = clone($filter); + static::fixFilterColumns($filter); + $helper = new AssignFilterHelper($filter); + + foreach (static::flatObjects($db) as $object) { + if ($helper->matches($object)) { + $name = $object->object_name; + $result[] = $name; + } + } + Benchmark::measure(sprintf('Got %d results for %s', count($result), $filter)); + + return array_values($result); + } + + protected static function getType() + { + if (static::$type === null) { + throw new ProgrammingError( + 'Implementations of %s need ::$type to be defined, %s has not', + __CLASS__, + get_called_class() + ); + } + + return static::$type; + } + + protected static function flatObjects(Db $db) + { + if (self::$flatObjects === null) { + self::$flatObjects = static::fetchFlatObjects($db); + } + + return self::$flatObjects; + } + + protected static function raiseLimits() + { + // Note: IcingaConfig also raises the limit for generation, **but** we + // need the higher limit for preview. + MemoryLimit::raiseTo('1024M'); + } + + protected static function fetchFlatObjects(Db $db) + { + return static::fetchFlatObjectsByType($db, static::getType()); + } + + protected static function fetchFlatObjectsByType(Db $db, $type) + { + self::raiseLimits(); + + Benchmark::measure("ObjectApplyMatches: prefetching $type"); + PrefetchCache::initialize($db); + /** @var IcingaObject $class */ + $class = DbObjectTypeRegistry::classByType($type); + $all = $class::prefetchAll($db); + Benchmark::measure("ObjectApplyMatches: related objects for $type"); + $class::prefetchAllRelationsByType($type, $db); + Benchmark::measure("ObjectApplyMatches: preparing flat $type objects"); + + $objects = array(); + foreach ($all as $object) { + if ($object->isTemplate()) { + continue; + } + + $flat = $object->toPlainObject(true, false); + static::flattenVars($flat); + $objects[$object->getObjectName()] = $flat; + } + Benchmark::measure("ObjectApplyMatches: $type cache ready"); + + return $objects; + } + + public static function fixFilterColumns(Filter $filter) + { + if ($filter->isExpression()) { + /** @var FilterExpression $filter */ + static::fixFilterExpressionColumn($filter); + } else { + foreach ($filter->filters() as $sub) { + static::fixFilterColumns($sub); + } + } + } + + protected static function fixFilterExpressionColumn(FilterExpression $filter) + { + if (static::columnIsJson($filter)) { + $column = $filter->getExpression(); + $filter->setExpression($filter->getColumn()); + $filter->setColumn($column); + } + + $col = $filter->getColumn(); + $type = static::$type; + + if ($type && substr($col, 0, strlen($type) + 1) === "${type}.") { + $filter->setColumn($col = substr($col, strlen($type) + 1)); + } + + if (array_key_exists($col, self::$columnMap)) { + $filter->setColumn(self::$columnMap[$col]); + } + + $filter->setExpression(json_decode($filter->getExpression())); + } + + protected static function columnIsJson(FilterExpression $filter) + { + $col = $filter->getColumn(); + return strlen($col) && $col[0] === '"'; + } + + /** + * Helper, flattens all vars of a given object + * + * The object itself will be modified, and the 'vars' property will be + * replaced with corresponding 'vars.whatever' properties + * + * @param $object + * @param string $key + */ + protected static function flattenVars(stdClass $object, $key = 'vars') + { + if (property_exists($object, 'vars')) { + foreach ($object->vars as $k => $v) { + if (is_object($v)) { + static::flattenVars($v, $k); + } + $object->{$key . '.' . $k} = $v; + } + unset($object->vars); + } + } + + protected function __construct(IcingaObject $object) + { + $this->object = $object; + $flat = $object->toPlainObject(true, false); + // Sure, we are flat - but we might still want to match templates. + unset($flat->imports); + $flat->templates = $object->listFlatResolvedImportNames(); + $this->addAppliedGroupsToFlatObject($flat, $object); + static::flattenVars($flat); + $this->flatObject = $flat; + } + + protected function addAppliedGroupsToFlatObject($flat, IcingaObject $object) + { + if ($object instanceof IcingaHost) { + $appliedGroups = $object->getAppliedGroups(); + if (! empty($appliedGroups)) { + if (isset($flat->groups)) { + $flat->groups = array_merge($flat->groups, $appliedGroups); + } else { + $flat->groups = $appliedGroups; + } + } + } + } +} diff --git a/library/Director/Objects/ObjectWithArguments.php b/library/Director/Objects/ObjectWithArguments.php new file mode 100644 index 0000000..2f99460 --- /dev/null +++ b/library/Director/Objects/ObjectWithArguments.php @@ -0,0 +1,18 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +interface ObjectWithArguments +{ + /** + * @return boolean + */ + public function gotArguments(); + + /** + * @return IcingaArguments + */ + public function arguments(); + + public function unsetArguments(); +} diff --git a/library/Director/Objects/ServiceGroupMembershipResolver.php b/library/Director/Objects/ServiceGroupMembershipResolver.php new file mode 100644 index 0000000..4649212 --- /dev/null +++ b/library/Director/Objects/ServiceGroupMembershipResolver.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +class ServiceGroupMembershipResolver extends GroupMembershipResolver +{ + protected $type = 'service'; +} diff --git a/library/Director/Objects/SyncProperty.php b/library/Director/Objects/SyncProperty.php new file mode 100644 index 0000000..20c4700 --- /dev/null +++ b/library/Director/Objects/SyncProperty.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Objects\Extension\PriorityColumn; + +class SyncProperty extends DbObject +{ + use PriorityColumn; + + protected $table = 'sync_property'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = [ + 'id' => null, + 'rule_id' => null, + 'source_id' => null, + 'source_expression' => null, + 'destination_field' => null, + 'priority' => null, + 'filter_expression' => null, + 'merge_policy' => null + ]; + + protected function beforeStore() + { + if (! $this->hasBeenLoadedFromDb() && $this->get('priority') === null) { + $this->setNextPriority('rule_id'); + } + } + + public function setSource($name) + { + $source = ImportSource::loadByName($name, $this->getConnection()); + $this->set('source_id', $source->get('id')); + + return $this; + } + + protected function onInsert() + { + $this->refreshPriortyProperty(); + } +} diff --git a/library/Director/Objects/SyncRule.php b/library/Director/Objects/SyncRule.php new file mode 100644 index 0000000..89f7fd1 --- /dev/null +++ b/library/Director/Objects/SyncRule.php @@ -0,0 +1,553 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Import\PurgeStrategy\PurgeStrategy; +use Icinga\Module\Director\Import\Sync; +use Exception; + +class SyncRule extends DbObject implements ExportInterface +{ + protected $table = 'sync_rule'; + + protected $keyName = 'rule_name'; + + protected $autoincKeyName = 'id'; + + protected $protectAutoinc = false; + + protected $defaultProperties = [ + 'id' => null, + 'rule_name' => null, + 'object_type' => null, + 'update_policy' => null, + 'purge_existing' => null, + 'purge_action' => null, + 'filter_expression' => null, + 'sync_state' => 'unknown', + 'last_error_message' => null, + 'last_attempt' => null, + 'description' => null, + ]; + + protected $stateProperties = [ + 'sync_state', + 'last_error_message', + 'last_attempt', + ]; + + private $sync; + + private $purgeStrategy; + + private $filter; + + private $hasCombinedKey; + + /** @var SyncProperty[] */ + private $syncProperties; + + private $sourceKeyPattern; + + private $destinationKeyPattern; + + private $newSyncProperties; + + private $originalId; + + public function listInvolvedSourceIds() + { + if (! $this->hasBeenLoadedFromDb()) { + return []; + } + + $db = $this->getDb(); + return array_map('intval', array_unique( + $db->fetchCol( + $db->select() + ->from(['p' => 'sync_property'], 'p.source_id') + ->join(['s' => 'import_source'], 's.id = p.source_id', array()) + ->where('rule_id = ?', $this->get('id')) + ->order('s.source_name') + ) + )); + } + + /** + * @return array + * @throws \Icinga\Exception\NotFoundError + */ + public function fetchInvolvedImportSources() + { + $sources = []; + + foreach ($this->listInvolvedSourceIds() as $sourceId) { + $sources[$sourceId] = ImportSource::loadWithAutoIncId($sourceId, $this->getConnection()); + } + + return $sources; + } + + public function getLastSyncTimestamp() + { + if (! $this->hasBeenLoadedFromDb()) { + return null; + } + + $db = $this->getDb(); + $query = $db->select()->from( + ['sr' => 'sync_run'], + 'sr.start_time' + )->where('sr.rule_id = ?', $this->get('id')) + ->order('sr.start_time DESC') + ->limit(1); + + return $db->fetchOne($query); + } + + public function getLastSyncRunId() + { + if (! $this->hasBeenLoadedFromDb()) { + return null; + } + + $db = $this->getDb(); + $query = $db->select()->from( + ['sr' => 'sync_run'], + 'sr.id' + )->where('sr.rule_id = ?', $this->get('id')) + ->order('sr.start_time DESC') + ->limit(1); + + return $db->fetchOne($query); + } + + public function matches($row) + { + if ($this->get('filter_expression') === null) { + return true; + } + + return $this->filter()->matches($row); + } + + /** + * @param bool $apply + * @return bool + * @throws DuplicateKeyException + */ + public function checkForChanges($apply = false) + { + $hadChanges = false; + + Benchmark::measure('Checking sync rule ' . $this->get('rule_name')); + try { + $this->set('last_attempt', date('Y-m-d H:i:s')); + $this->set('sync_state', 'unknown'); + $sync = $this->sync(); + if ($sync->hasModifications()) { + Benchmark::measure('Got modifications for sync rule ' . $this->get('rule_name')); + $this->set('sync_state', 'pending-changes'); + if ($apply && $runId = $sync->apply()) { + Benchmark::measure('Successfully synced rule ' . $this->get('rule_name')); + $this->set('sync_state', 'in-sync'); + } + + $hadChanges = true; + } else { + Benchmark::measure('No modifications for sync rule ' . $this->get('rule_name')); + $this->set('sync_state', 'in-sync'); + } + + $this->set('last_error_message', null); + } catch (Exception $e) { + $this->set('sync_state', 'failing'); + $this->set('last_error_message', $e->getMessage()); + // TODO: Store last error details / trace? + } + + if ($this->hasBeenModified()) { + $this->store(); + } + + return $hadChanges; + } + + /** + * @return IcingaObject[] + * @throws IcingaException + */ + public function getExpectedModifications() + { + return $this->sync()->getExpectedModifications(); + } + + /** + * @return bool + * @throws DuplicateKeyException + */ + public function applyChanges() + { + return $this->checkForChanges(true); + } + + public function getSourceKeyPattern() + { + if ($this->hasCombinedKey()) { + return $this->sourceKeyPattern; + } else { + return null; // ?? + } + } + + public function getDestinationKeyPattern() + { + if ($this->hasCombinedKey()) { + return $this->destinationKeyPattern; + } else { + return null; // ?? + } + } + + protected function sync() + { + if ($this->sync === null) { + $this->sync = new Sync($this); + } + + return $this->sync; + } + + /** + * @return Filter + */ + public function filter() + { + if ($this->filter === null) { + $this->filter = Filter::fromQueryString($this->get('filter_expression')); + } + + return $this->filter; + } + + public function purgeStrategy() + { + if ($this->purgeStrategy === null) { + $this->purgeStrategy = $this->loadConfiguredPurgeStrategy(); + } + + return $this->purgeStrategy; + } + + // TODO: Allow for more + protected function loadConfiguredPurgeStrategy() + { + if ($this->get('purge_existing') === 'y') { + return PurgeStrategy::load('ImportRunBased', $this); + } else { + return PurgeStrategy::load('PurgeNothing', $this); + } + } + + /** + * @deprecated please use \Icinga\Module\Director\Data\Exporter + * @return object + */ + public function export() + { + $plain = $this->getProperties(); + $plain['originalId'] = $plain['id']; + unset($plain['id']); + + foreach ($this->stateProperties as $key) { + unset($plain[$key]); + } + $plain['properties'] = $this->exportSyncProperties(); + ksort($plain); + + return (object) $plain; + } + + /** + * @param object $plain + * @param Db $db + * @param bool $replace + * @return static + * @throws DuplicateKeyException + * @throws \Icinga\Exception\NotFoundError + */ + public static function import($plain, Db $db, $replace = false) + { + $properties = (array) $plain; + if (isset($properties['originalId'])) { + $id = $properties['originalId']; + unset($properties['originalId']); + } else { + $id = null; + } + $name = $properties['rule_name']; + + if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) { + $object = static::loadWithAutoIncId($id, $db); + } elseif ($replace && static::exists($name, $db)) { + $object = static::load($name, $db); + } elseif (static::existsWithName($name, $db)) { + throw new DuplicateKeyException( + 'Sync Rule %s already exists', + $name + ); + } else { + $object = static::create([], $db); + } + + $object->newSyncProperties = $properties['properties']; + unset($properties['properties']); + $object->setProperties($properties); + + return $object; + } + + public function getUniqueIdentifier() + { + return $this->get('rule_name'); + } + + /** + * @throws DuplicateKeyException + */ + protected function onStore() + { + parent::onStore(); + if ($this->newSyncProperties !== null) { + $connection = $this->getConnection(); + $db = $connection->getDbAdapter(); + $myId = $this->get('id'); + if ($this->originalId === null) { + $originalId = $myId; + } else { + $originalId = $this->originalId; + $this->originalId = null; + } + if ($this->hasBeenLoadedFromDb()) { + $db->delete( + 'sync_property', + $db->quoteInto('rule_id = ?', $myId) + ); + } + + foreach ($this->newSyncProperties as $property) { + unset($property->rule_name); + $property = SyncProperty::create((array) $property, $connection); + $property->set('rule_id', $myId); + $property->store(); + } + } + } + + /** + * @deprecated + * @return array + */ + protected function exportSyncProperties() + { + $all = []; + $db = $this->getDb(); + $sourceNames = $db->fetchPairs( + $db->select()->from('import_source', ['id', 'source_name']) + ); + + foreach ($this->getSyncProperties() as $property) { + $properties = $property->getProperties(); + $properties['source'] = $sourceNames[$properties['source_id']]; + unset($properties['id']); + unset($properties['rule_id']); + unset($properties['source_id']); + ksort($properties); + $all[] = (object) $properties; + } + + return $all; + } + + /** + * Whether we have a combined key (e.g. services on hosts) + * + * @return bool + */ + public function hasCombinedKey() + { + if ($this->hasCombinedKey === null) { + $this->hasCombinedKey = false; + + // TODO: Move to Objects + if ($this->get('object_type') === 'service') { + $hasHost = false; + $hasObjectName = false; + $hasServiceSet = false; + + foreach ($this->getSyncProperties() as $key => $property) { + if ($property->destination_field === 'host') { + $hasHost = $property->source_expression; + } + if ($property->destination_field === 'service_set') { + $hasServiceSet = $property->source_expression; + } + if ($property->destination_field === 'object_name') { + $hasObjectName = $property->source_expression; + } + } + + if ($hasHost !== false && $hasObjectName !== false) { + $this->hasCombinedKey = true; + $this->sourceKeyPattern = sprintf( + '%s!%s', + $hasHost, + $hasObjectName + ); + + $this->destinationKeyPattern = '${host}!${object_name}'; + } elseif ($hasServiceSet !== false && $hasObjectName !== false) { + $this->hasCombinedKey = true; + $this->sourceKeyPattern = sprintf( + '%s!%s', + $hasServiceSet, + $hasObjectName + ); + + $this->destinationKeyPattern = '${service_set}!${object_name}'; + } + } elseif ($this->get('object_type') === 'serviceSet') { + $hasHost = false; + $hasObjectName = false; + + foreach ($this->getSyncProperties() as $key => $property) { + if ($property->destination_field === 'host') { + $hasHost = $property->source_expression; + } + if ($property->destination_field === 'object_name') { + $hasObjectName = $property->source_expression; + } + } + + if ($hasHost !== false && $hasObjectName !== false) { + $this->hasCombinedKey = true; + $this->sourceKeyPattern = sprintf( + '%s!%s', + $hasHost, + $hasObjectName + ); + + $this->destinationKeyPattern = '${host}!${object_name}'; + } + } elseif ($this->get('object_type') === 'datalistEntry') { + $hasList = false; + $hasName = false; + + foreach ($this->getSyncProperties() as $key => $property) { + if ($property->destination_field === 'list_id') { + $hasList = $property->source_expression; + } + if ($property->destination_field === 'entry_name') { + $hasName = $property->source_expression; + } + } + + if ($hasList !== false && $hasName !== false) { + $this->hasCombinedKey = true; + $this->sourceKeyPattern = sprintf( + '%s!%s', + $hasList, + $hasName + ); + + $this->destinationKeyPattern = '${list_id}!${entry_name}'; + } + } + } + + return $this->hasCombinedKey; + } + + public function hasSyncProperties() + { + $properties = $this->getSyncProperties(); + return ! empty($properties); + } + + /** + * @return SyncProperty[] + */ + public function getSyncProperties() + { + if (! $this->hasBeenLoadedFromDb()) { + return []; + } + + if ($this->syncProperties === null) { + $this->syncProperties = $this->fetchSyncProperties(); + } + + return $this->syncProperties; + } + + public function fetchSyncProperties() + { + $db = $this->getDb(); + + return SyncProperty::loadAll( + $this->getConnection(), + $db->select() + ->from('sync_property') + ->where('rule_id = ?', $this->get('id')) + ->order('priority ASC') + ); + } + + /** + * TODO: implement in a generic way, this is duplicated code + * + * @param string $name + * @param Db $connection + * @api internal + * @return bool + */ + public static function existsWithName($name, Db $connection) + { + $db = $connection->getDbAdapter(); + + return (string) $name === (string) $db->fetchOne( + $db->select() + ->from('sync_rule', 'rule_name') + ->where('rule_name = ?', $name) + ); + } + + /** + * @param string $name + * @param int $id + * @param Db $connection + * @api internal + * @return bool + */ + protected static function existsWithNameAndId($name, $id, Db $connection) + { + $db = $connection->getDbAdapter(); + $dummy = new static; + $idCol = $dummy->autoincKeyName; + $keyCol = $dummy->keyName; + + return (string) $id === (string) $db->fetchOne( + $db->select() + ->from($dummy->table, $idCol) + ->where("$idCol = ?", $id) + ->where("$keyCol = ?", $name) + ); + } +} diff --git a/library/Director/Objects/SyncRun.php b/library/Director/Objects/SyncRun.php new file mode 100644 index 0000000..62f7378 --- /dev/null +++ b/library/Director/Objects/SyncRun.php @@ -0,0 +1,46 @@ +<?php + +namespace Icinga\Module\Director\Objects; + +use Icinga\Module\Director\Data\Db\DbObject; + +class SyncRun extends DbObject +{ + protected $table = 'sync_run'; + + protected $keyName = 'id'; + + protected $autoincKeyName = 'id'; + + protected $defaultProperties = array( + 'id' => null, + 'rule_id' => null, + 'rule_name' => null, + 'start_time' => null, + 'duration_ms' => null, + 'objects_created' => null, + 'objects_deleted' => null, + 'objects_modified' => null, + 'last_former_activity' => null, + 'last_related_activity' => null, + ); + + public static function start(SyncRule $rule) + { + return static::create( + array( + 'start_time' => date('Y-m-d H:i:s'), + 'rule_id' => $rule->id, + 'rule_name' => $rule->rule_name, + ), + $rule->getConnection() + ); + } + + public function countActivities() + { + return (int) $this->get('objects_deleted') + + (int) $this->get('objects_created') + + (int) $this->get('objects_modified'); + } +} diff --git a/library/Director/PlainObjectRenderer.php b/library/Director/PlainObjectRenderer.php new file mode 100644 index 0000000..4dadf4f --- /dev/null +++ b/library/Director/PlainObjectRenderer.php @@ -0,0 +1,130 @@ +<?php + +namespace Icinga\Module\Director; + +class PlainObjectRenderer +{ + const INDENTATION = ' '; + + public static function render($object) + { + return self::renderObject($object); + } + + protected static function renderBoolean($value) + { + return $value ? 'true' : 'false'; + } + + protected static function renderInteger($value) + { + return (string) $value; + } + + protected static function renderFloat($value) + { + // Render .0000 floats as integers, mainly because of some JSON + // implementations: + if ((string) (int) $value === (string) $value) { + return static::renderInteger((int) $value); + } else { + return sprintf('%F', $value); + } + } + + protected static function renderNull() + { + return 'null'; + } + + protected static function renderString($value) + { + return '"' . addslashes($value) . '"'; + } + + protected static function renderArray($array, $prefix = '') + { + if (empty($array)) { + return '[]'; + } + + $vals = array(); + + foreach ($array as $val) { + $vals[] = $prefix + . self::INDENTATION + . self::renderObject($val, $prefix . self::INDENTATION); + } + return "[\n" . implode(",\n", $vals) . "\n$prefix]"; + } + + protected static function renderHash($hash, $prefix = '') + { + $vals = array(); + $hash = (array) $hash; + if (empty($hash)) { + return '{}'; + } + + if (count($hash) === 1) { + $current = self::renderObject(current($hash), $prefix . self::INDENTATION); + if (strlen($current) < 62) { + return sprintf( + '{ %s: %s }', + key($hash), + $current + ); + } + } + + ksort($hash); + foreach ($hash as $key => $val) { + $vals[] = $prefix + . self::INDENTATION + . $key + . ': ' + . self::renderObject($val, $prefix . self::INDENTATION); + } + return "{\n" . implode(",\n", $vals) . "\n$prefix}"; + } + + protected static function renderObject($object, $prefix = '') + { + if (is_null($object)) { + return self::renderNull(); + } elseif (is_bool($object)) { + return self::renderBoolean($object); + } elseif (is_integer($object)) { + return self::renderInteger($object); + } elseif (is_float($object)) { + return self::renderFloat($object); + } elseif (is_object($object) || static::isAssocArray($object)) { + return self::renderHash($object, $prefix); + } elseif (is_array($object)) { + return self::renderArray($object, $prefix); + } elseif (is_string($object)) { + return self::renderString($object); + } else { + return '(UNKNOWN TYPE) ' . var_export($object, 1); + } + } + + /** + * Check if an array contains assoc keys + * + * @from https://stackoverflow.com/questions/173400/how-to-check-if-php-array-is-associative-or-sequential + * @param $arr + * @return bool + */ + protected static function isAssocArray($arr) + { + if (! is_array($arr)) { + return false; + } + if (array() === $arr) { + return false; + } + + return array_keys($arr) !== range(0, count($arr) - 1); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php b/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php new file mode 100644 index 0000000..0ffc8af --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php @@ -0,0 +1,172 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; +use stdClass; + +class PropertyModifierArrayElementByPosition extends PropertyModifierHook +{ + public function getName() + { + return 'Get a specific Array Element'; + } + + public function hasArraySupport() + { + return true; + } + + /** + * @param QuickForm $form + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'position_type', [ + 'label' => $form->translate('Position Type'), + 'required' => true, + 'multiOptions' => $form->optionalEnum([ + 'first' => $form->translate('First Element'), + 'last' => $form->translate('Last Element'), + 'fixed' => $form->translate('Specific Element (by position)'), + 'keyname' => $form->translate('Specific Element (by key name)'), + ]), + ]); + + $form->addElement('text', 'position', [ + 'label' => $form->translate('Position'), + 'description' => $form->translate( + 'Numeric position or key name' + ), + ]); + + $form->addElement('select', 'when_missing', [ + 'label' => $form->translate('When not available'), + 'required' => true, + 'description' => $form->translate( + 'What should happen when the specified element is not available?' + ), + 'value' => 'null', + 'multiOptions' => $form->optionalEnum([ + 'fail' => $form->translate('Let the whole Import Run fail'), + 'null' => $form->translate('return NULL'), + ]) + ]); + } + + /** + * @param $value + * @return string|null + * @throws ConfigurationError + * @throws InvalidArgumentException + */ + public function transform($value) + { + // First and Last will work with hashes too: + if ($value instanceof stdClass) { + $value = (array) $value; + } + + if (! is_array($value)) { + return $this->emptyValue($value); + } + + switch ($this->getSetting('position_type')) { + case 'first': + if (empty($value)) { + return $this->emptyValue($value); + } else { + return array_shift($value); + } + // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363 + case 'last': + if (empty($value)) { + return $this->emptyValue($value); + } else { + return array_pop($value); + } + // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363 + case 'fixed': + $pos = $this->getSetting('position'); + if (! is_int($pos) && ! ctype_digit($pos)) { + throw new InvalidArgumentException(sprintf( + '"%s" is not a valid array position', + $pos + )); + } + $pos = (int) $pos; + + if (array_key_exists($pos, $value)) { + return $value[$pos]; + } else { + return $this->emptyValue($value); + } + // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363 + case 'keyname': + $pos = $this->getSetting('position'); + if (! is_string($pos)) { + throw new InvalidArgumentException(sprintf( + '"%s" is not a valid array key name', + $pos + )); + } + + if (array_key_exists($pos, $value)) { + return $value[$pos]; + } else { + return $this->emptyValue($value); + } + // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363 + default: + throw new ConfigurationError( + '"%s" is not a valid array position_type', + $this->getSetting('position_type') + ); + } + } + + /** + * @return string + * @throws ConfigurationError + */ + protected function getPositionForError() + { + switch ($this->getSetting('position_type')) { + case 'first': + return 'first'; + case 'last': + return 'last'; + case 'fixed': + return '#' . $this->getSetting('position'); + case 'keyname': + return '#' . $this->getSetting('position'); + default: + throw new ConfigurationError( + '"%s" is not a valid array position_type', + $this->getSetting('position_type') + ); + } + } + + /** + * @param $value + * @return null + * @throws ConfigurationError + */ + protected function emptyValue($value) + { + if ($this->getSetting('when_missing', 'fail') === 'null') { + return null; + } else { + throw new InvalidArgumentException(sprintf( + 'There is no %s element in %s', + $this->getPositionForError(), + json_encode($value) + )); + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierArrayFilter.php b/library/Director/PropertyModifier/PropertyModifierArrayFilter.php new file mode 100644 index 0000000..0b52987 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierArrayFilter.php @@ -0,0 +1,152 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierArrayFilter extends PropertyModifierHook +{ + /** @var FilterExpression */ + private $filterExpression; + + public function getName() + { + return 'Filter Array Values'; + } + + public function hasArraySupport() + { + return true; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'filter_method', array( + 'label' => $form->translate('Filter method'), + 'required' => true, + 'value' => 'wildcard', + 'multiOptions' => $form->optionalEnum(array( + 'wildcard' => $form->translate('Simple match with wildcards (*)'), + 'regex' => $form->translate('Regular Expression'), + )), + )); + + $form->addElement('text', 'filter_string', array( + 'label' => 'Filter', + 'description' => $form->translate( + 'The string/pattern you want to search for. Depends on the' + . ' chosen method, use www.* or *linux* for wildcard matches' + . ' and expression like /^www\d+\./ in case you opted for a' + . ' regular expression' + ), + 'required' => true, + )); + + $form->addElement('select', 'policy', array( + 'label' => $form->translate('Policy'), + 'required' => true, + 'description' => $form->translate( + 'What should happen with matching elements?' + ), + 'value' => 'keep', + 'multiOptions' => array( + 'keep' => $form->translate('Keep matching elements'), + 'reject' => $form->translate('Reject matching elements'), + ), + )); + + $form->addElement('select', 'when_empty', array( + 'label' => $form->translate('When empty'), + 'required' => true, + 'description' => $form->translate( + 'What should happen when the result array is empty?' + ), + 'value' => 'empty_array', + 'multiOptions' => $form->optionalEnum(array( + 'empty_array' => $form->translate('return an empty array'), + 'null' => $form->translate('return NULL'), + )) + )); + } + + public function matchesRegexp($string, $expression) + { + return preg_match($expression, $string); + } + + public function matchesWildcard($string, $expression) + { + return $this->filterExpression->matches( + (object) array('value' => $string) + ); + } + + public function transform($value) + { + if (empty($value)) { + return $this->emptyValue(); + } + + if (is_string($value)) { + $value = [$value]; + } + + if (! is_array($value)) { + throw new InvalidPropertyException( + 'The ArrayFilter property modifier be applied to arrays only' + ); + } + + $method = $this->getSetting('filter_method'); + $filter = $this->getSetting('filter_string'); + $policy = $this->getSetting('policy'); + + switch ($method) { + case 'wildcard': + $func = 'matchesWildcard'; + $this->filterExpression = new FilterExpression('value', '=', $filter); + break; + case 'regex': + $func = 'matchesRegexp'; + break; + default: + throw new ConfigurationError( + '%s is not a valid value for an ArrayFilter filter_method', + var_export($method, 1) + ); + } + + $result = array(); + + foreach ($value as $val) { + if ($this->$func($val, $filter)) { + if ($policy === 'keep') { + $result[] = $val; + } + } else { + if ($policy === 'reject') { + $result[] = $val; + } + } + } + + if (empty($result)) { + return $this->emptyValue(); + } + + return $result; + } + + protected function emptyValue() + { + if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') { + return array(); + } else { + return null; + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierArrayToRow.php b/library/Director/PropertyModifier/PropertyModifierArrayToRow.php new file mode 100644 index 0000000..35ae063 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierArrayToRow.php @@ -0,0 +1,68 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; +use ipl\Html\Error; + +class PropertyModifierArrayToRow extends PropertyModifierHook +{ + public function getName() + { + return 'Clone the row for every entry of an Array'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'on_empty', [ + 'label' => 'When empty', + 'description' => $form->translate('What should we do in case the given value is empty?'), + 'multiOptions' => $form->optionalEnum([ + 'reject' => $form->translate('Drop the current row'), + 'fail' => $form->translate('Let the whole import run fail'), + 'keep' => $form->translate('Keep the row, set the column value to null'), + ]), + 'value' => 'reject', + 'required' => true, + ]); + } + + public function hasArraySupport() + { + return true; + } + + public function expandsRows() + { + return true; + } + + public function transform($value) + { + if (empty($value)) { + $onDuplicate = $this->getSetting('on_empty', 'reject'); + switch ($onDuplicate) { + case 'reject': + return []; + case 'keep': + return [null]; + case 'fail': + throw new InvalidArgumentException('Failed to clone row, value is empty'); + default: + throw new InvalidArgumentException( + "'$onDuplicate' is not a valid 'on_duplicate' setting" + ); + } + } + + if (! \is_array($value)) { + throw new InvalidArgumentException( + "Array required to clone this row, got " . Error::getPhpTypeName($value) + ); + } + + return $value; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierArrayUnique.php b/library/Director/PropertyModifier/PropertyModifierArrayUnique.php new file mode 100644 index 0000000..e3446f9 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierArrayUnique.php @@ -0,0 +1,37 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use function array_unique; +use function array_values; +use function is_array; + +class PropertyModifierArrayUnique extends PropertyModifierHook +{ + public function getName() + { + return 'Unique Array Values'; + } + + public function hasArraySupport() + { + return true; + } + + public function transform($value) + { + if (empty($value)) { + return $value; + } + + if (! is_array($value)) { + throw new InvalidPropertyException( + 'The ArrayUnique property modifier can be applied to arrays only' + ); + } + + return array_values(array_unique($value)); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierBitmask.php b/library/Director/PropertyModifier/PropertyModifierBitmask.php new file mode 100644 index 0000000..d334f09 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierBitmask.php @@ -0,0 +1,39 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierBitmask extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'bitmask', array( + 'label' => 'Bitmask', + 'description' => $form->translate( + 'The numeric bitmask you want to apply. In case you have a hexadecimal' + . ' or binary mask please transform it to a decimal number first. The' + . ' result of this modifier is a boolean value, telling whether the' + . ' given mask applies to the numeric value in your source column' + ), + 'required' => true, + )); + } + + public function getName() + { + return 'Bitmask match (numeric)'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $mask = (int) $this->getSetting('bitmask'); + return (((int) $value) & $mask) === $mask; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierCombine.php b/library/Director/PropertyModifier/PropertyModifierCombine.php new file mode 100644 index 0000000..5be09ea --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierCombine.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Import\SyncUtils; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierCombine extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'pattern', array( + 'label' => $form->translate('Pattern'), + 'required' => false, + 'description' => $form->translate( + 'This pattern will be evaluated, and variables like ${some_column}' + . ' will be filled accordingly. A typical use-case is generating' + . ' unique service identifiers via ${host}!${service} in case your' + . ' data source doesn\'t allow you to ship such. The chosen "property"' + . ' has no effect here and will be ignored.' + ) + )); + } + + public function getName() + { + return 'Combine multiple properties'; + } + + public function requiresRow() + { + return true; + } + + public function transform($value) + { + return SyncUtils::fillVariables($this->getSetting('pattern'), $this->getRow()); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php b/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php new file mode 100644 index 0000000..2a60ab3 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php @@ -0,0 +1,94 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Data\InvalidDataException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; +use ipl\Html\Error; + +class PropertyModifierDictionaryToRow extends PropertyModifierHook +{ + public function getName() + { + return 'Clone the row for every entry of a nested Dictionary/Hash structure'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'key_column', [ + 'label' => $form->translate('Key Property Name'), + 'description' => $form->translate( + 'Every Dictionary entry has a key, its value will be provided in this column' + ) + ]); + $form->addElement('select', 'on_empty', [ + 'label' => $form->translate('When empty'), + 'description' => $form->translate('What should we do in case the given value is empty?'), + 'multiOptions' => $form->optionalEnum([ + 'reject' => $form->translate('Drop the current row'), + 'fail' => $form->translate('Let the whole import run fail'), + 'keep' => $form->translate('Keep the row, set the column value to null'), + ]), + 'value' => 'reject', + 'required' => true, + ]); + } + + public function requiresRow() + { + return true; + } + + public function hasArraySupport() + { + return true; + } + + public function expandsRows() + { + return true; + } + + public function transform($value) + { + if (empty($value)) { + $onDuplicate = $this->getSetting('on_empty', 'reject'); + switch ($onDuplicate) { + case 'reject': + return []; + case 'keep': + return [null]; + case 'fail': + throw new InvalidArgumentException('Failed to clone row, value is empty'); + default: + throw new InvalidArgumentException( + "'$onDuplicate' is not a valid 'on_duplicate' setting" + ); + } + } + + $keyColumn = $this->getSetting('key_column'); + + if (! \is_object($value)) { + throw new InvalidArgumentException( + "Object required to clone this row, got " . Error::getPhpTypeName($value) + ); + } + $result = []; + foreach ($value as $key => $properties) { + if (! is_object($properties)) { + throw new InvalidDataException( + sprintf('Nested "%s" dictionary', $key), + $properties + ); + } + + $properties->$keyColumn = $key; + $result[] = $properties; + } + + return $result; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierDnsRecords.php b/library/Director/PropertyModifier/PropertyModifierDnsRecords.php new file mode 100644 index 0000000..d5d8d41 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierDnsRecords.php @@ -0,0 +1,112 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierDnsRecords extends PropertyModifierHook +{ + protected static $types = array( + 'A' => DNS_A, + 'AAAA' => DNS_AAAA, + 'CNAME' => DNS_CNAME, + 'MX' => DNS_MX, + 'NS' => DNS_NS, + 'PTR' => DNS_PTR, + 'TXT' => DNS_TXT, + ); + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'record_type', array( + 'label' => 'Record type', + 'description' => $form->translate('DNS record type'), + 'multiOptions' => $form->optionalEnum(static::enumTypes()), + 'required' => true, + )); + + $form->addElement('select', 'on_failure', array( + 'label' => 'On failure', + 'description' => $form->translate('What should we do if the DNS lookup fails?'), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the property as is'), + 'fail' => $form->translate('Let the whole import run fail'), + )), + 'required' => true, + )); + } + + protected static function enumTypes() + { + $types = array_keys(self::$types); + return array_combine($types, $types); + } + + public function getName() + { + return 'Get DNS records of a specific type'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $type = self::$types[$this->getSetting('record_type')]; + $response = dns_get_record($value, $type); + + if ($response === false) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'DNS lookup failed for "%s"', + $value + ); + } + } + + $result = array(); + switch ($type) { + case DNS_A: + return $this->extractProperty('ip', $response); + case DNS_AAAA: + return $this->extractProperty('ipv6', $response); + case DNS_CNAME: + case DNS_MX: + case DNS_NS: + case DNS_PTR: + return $this->extractProperty('target', $response); + case DNS_TXT: + return $this->extractProperty('txt', $response); + return $response; + } + + return $result; + } + + protected function extractProperty($key, $response) + { + $result = array(); + foreach ($response as $entry) { + $result[] = $entry[$key]; + } + + if (version_compare(PHP_VERSION, '5.4.0') >= 0) { + sort($result, SORT_NATURAL); + } else { + natsort($result); + $result = array_values($result); + } + + return $result; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php b/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php new file mode 100644 index 0000000..c79c5b2 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php @@ -0,0 +1,99 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Protocol\Ldap\LdapUtils; + +class PropertyModifierExtractFromDN extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'dn_component', array( + 'label' => $form->translate('DN component'), + 'description' => $form->translate('What should we extract from the DN?'), + 'multiOptions' => $form->optionalEnum(array( + 'cn' => $form->translate('The first (leftmost) CN'), + 'ou' => $form->translate('The first (leftmost) OU'), + 'first' => $form->translate('Any first (leftmost) component'), + 'last_ou' => $form->translate('The last (rightmost) OU'), + )), + 'required' => true, + )); + + $form->addElement('select', 'on_failure', array( + 'label' => $form->translate('On failure'), + 'description' => $form->translate('What should we do if the desired part does not exist?'), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the DN as is'), + 'fail' => $form->translate('Let the whole import run fail'), + )), + 'required' => true, + )); + } + + public function getName() + { + return 'Extract from a Distinguished Name (DN)'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $parts = LdapUtils::explodeDN($value); + $result = null; + + switch ($this->getSetting('dn_component')) { + case 'cn': + $result = $this->extractFirst($parts, 'cn'); + break; + case 'ou': + $result = $this->extractFirst($parts, 'ou'); + break; + case 'last_ou': + $result = $this->extractFirst(array_reverse($parts), 'ou'); + break; + case 'first': + $result = $this->extractFirst($parts); + break; + } + + if ($result === null) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'DN part extraction failed for %s', + var_export($value, 1) + ); + } + } + + return $result; + } + + protected function extractFirst($parts, $what = null) + { + foreach ($parts as $part) { + if (false === ($pos = strpos($part, '='))) { + continue; + } + + if (null === $what || strtolower(substr($part, 0, $pos)) === $what) { + return substr($part, $pos + 1); + } + } + + return null; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierFromAdSid.php b/library/Director/PropertyModifier/PropertyModifierFromAdSid.php new file mode 100644 index 0000000..ee306e3 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierFromAdSid.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierFromAdSid extends PropertyModifierHook +{ + public function getName() + { + return 'Decode a binary object SID (MSAD)'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + // Strongly inspired by + // http://www.chadsikorra.com/blog/decoding-and-encoding-active-directory-objectsid-php + // + // Not perfect yet, but should suffice for now. When improving this please also see: + // https://blogs.msdn.microsoft.com/oldnewthing/20040315-00/?p=40253 + + $sid = $value; + $sidHex = unpack('H*hex', $value); + $sidHex = $sidHex['hex']; + $subAuths = implode('-', unpack('H2/H2/n/N/V*', $sid)); + + $revLevel = hexdec(substr($sidHex, 0, 2)); + $authIdent = hexdec(substr($sidHex, 4, 12)); + + return sprintf('S-%s-%s-%s', $revLevel, $authIdent, $subAuths); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierFromLatin1.php b/library/Director/PropertyModifier/PropertyModifierFromLatin1.php new file mode 100644 index 0000000..272956c --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierFromLatin1.php @@ -0,0 +1,23 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use function iconv; + +class PropertyModifierFromLatin1 extends PropertyModifierHook +{ + public function getName() + { + return 'Convert a latin1 string to utf8'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + return iconv('ISO-8859-15', 'UTF-8', $value); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php b/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php new file mode 100644 index 0000000..2b0f9c3 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierGetHostByAddr extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'on_failure', array( + 'label' => 'On failure', + 'description' => $form->translate('What should we do if the host (DNS) lookup fails?'), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the property (hostname) as is'), + 'fail' => $form->translate('Let the whole import run fail'), + )), + 'required' => true, + )); + } + + public function getName() + { + return mt('director', 'Get host by address (Reverse DNS lookup)'); + } + + public function transform($value) + { + if ($value === null) { + return null; + } + $host = gethostbyaddr($value); + if ($host === false) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'Reverse Host lookup failed for "%s"', + $value + ); + } + } + + return $host; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierGetHostByName.php b/library/Director/PropertyModifier/PropertyModifierGetHostByName.php new file mode 100644 index 0000000..36884e8 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierGetHostByName.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierGetHostByName extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'on_failure', array( + 'label' => 'On failure', + 'description' => $form->translate('What should we do if the host (DNS) lookup fails?'), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the property (hostname) as is'), + 'fail' => $form->translate('Let the whole import run fail'), + )), + 'required' => true, + )); + } + + public function getName() + { + return mt('director', 'Get host by name (DNS lookup)'); + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $host = gethostbyname($value); + if (strlen(@inet_pton($host)) !== 4) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'Host lookup failed for "%s"', + $value + ); + } + } + + return $host; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php b/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php new file mode 100644 index 0000000..d57b427 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php @@ -0,0 +1,129 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Objects\ImportRowModifier; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierGetPropertyFromOtherImportSource extends PropertyModifierHook +{ + protected $importSource; + + private $importedData; + + public function getName() + { + return 'Get a property from another Import Source'; + } + + /** + * @inheritdoc + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + if (! $form instanceof DirectorObjectForm) { + throw new \RuntimeException('This property modifier works only with a DirectorObjectForm'); + } + $db = $form->getDb(); + $form->addElement('select', 'import_source_id', [ + 'label' => $form->translate('Import Source'), + 'description' => $form->translate( + 'Another Import Source. We\'re going to look up the row with the' + . ' key matching the value in the chosen column' + ), + 'required' => true, + 'multiOptions' => $form->optionalEnum($db->enumImportSource()), + 'class' => 'autosubmit', + ]); + + if ($form->hasBeenSent()) { + $sourceId = $form->getSentValue('import_source_id'); + } else { + $object = $form->getObject(); + if ($object instanceof ImportRowModifier) { + $sourceId = $object->getSetting('import_source_id'); + } else { + $sourceId = null; + } + } + $extra = []; + if ($sourceId) { + $extra = [ + 'class' => 'director-suggest', + 'data-suggestion-context' => 'importsourceproperties!' . (int) $sourceId, + ]; + } + $form->addElement('text', 'foreign_property', [ + 'label' => $form->translate('Property'), + 'required' => true, + 'description' => $form->translate( + 'The property to get from the row we found in the chosen Import Source' + ), + ] + $extra); + } + + /** + * @param $settings + * @return PropertyModifierHook + * @throws \Icinga\Exception\NotFoundError + */ + public function setSettings(array $settings) + { + if (isset($settings['import_source'])) { + $settings['import_source_id'] = ImportSource::load( + $settings['import_source'], + $this->getDb() + )->get('id'); + unset($settings['import_source']); + } + + return parent::setSettings($settings); + } + + public function transform($value) + { + $data = $this->getImportedData(); + + if (isset($data[$value])) { + return $data[$value]->{$this->getSetting('foreign_property')}; + } else { + return null; + } + } + + public function exportSettings() + { + $settings = parent::exportSettings(); + $settings->import_source = $this->getImportSource()->getObjectName(); + unset($settings->import_source_id); + + return $settings; + } + + protected function & getImportedData() + { + if ($this->importedData === null) { + $this->importedData = $this->getImportSource() + ->fetchLastRun(true) + ->fetchRows([$this->getSetting('foreign_property')]); + } + + return $this->importedData; + } + + protected function getImportSource() + { + if ($this->importSource === null) { + $this->importSource = ImportSource::loadWithAutoIncId( + $this->getSetting('import_source_id'), + $this->getDb() + ); + } + + return $this->importSource; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierJoin.php b/library/Director/PropertyModifier/PropertyModifierJoin.php new file mode 100644 index 0000000..daa6fdb --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierJoin.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierJoin extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'glue', array( + 'label' => $form->translate('Glue'), + 'required' => false, + 'description' => $form->translate( + 'One or more characters that will be used to glue an input array to a string. Can be left empty' + ) + )); + } + + public function hasArraySupport() + { + return true; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + return implode($this->getSetting('glue'), $value); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierJsonDecode.php b/library/Director/PropertyModifier/PropertyModifierJsonDecode.php new file mode 100644 index 0000000..f6b9af8 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierJsonDecode.php @@ -0,0 +1,68 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Exception\JsonException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierJsonDecode extends PropertyModifierHook +{ + /** + * @param QuickForm $form + * @return QuickForm|void + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'on_failure', array( + 'label' => 'On failure', + 'description' => $form->translate( + 'What should we do in case we are unable to decode the given string?' + ), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the JSON string as is'), + 'fail' => $form->translate('Let the whole import run fail'), + )), + 'required' => true, + )); + } + + public function getName() + { + return 'Decode a JSON string'; + } + + /** + * @param $value + * @return mixed|null + * @throws InvalidPropertyException + */ + public function transform($value) + { + if (null === $value) { + return $value; + } + + $decoded = @json_decode($value); + if ($decoded === null && JSON_ERROR_NONE !== json_last_error()) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'JSON decoding failed with "%s" for %s', + JsonException::getJsonErrorMessage(json_last_error()), + substr($value, 0, 128) + ); + } + } + + return $decoded; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php b/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php new file mode 100644 index 0000000..35db6c8 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierLConfCustomVar extends PropertyModifierHook +{ + public function transform($value) + { + if ($value === null) { + return null; + } + + $vars = (object) array(); + $this->extractLConfVars($value, $vars); + + return $vars; + } + + public function getName() + { + return 'Transform LConf CustomVars to Hash'; + } + + public function hasArraySupport() + { + return true; + } + + protected function extractLConfVars($value, $vars) + { + if (is_string($value)) { + $this->extractLConfVar($value, $vars); + } elseif (is_array($value)) { + foreach ($value as $val) { + $this->extractLConfVar($val, $vars); + } + } + } + + protected function extractLConfVar($value, $vars) + { + list($key, $val) = preg_split('/ /', $value, 2); + $key = ltrim($key, '_'); + $vars->$key = $val; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierListToObject.php b/library/Director/PropertyModifier/PropertyModifierListToObject.php new file mode 100644 index 0000000..9889c8f --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierListToObject.php @@ -0,0 +1,95 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; +use ipl\Html\Error; + +class PropertyModifierListToObject extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'key_property', [ + 'label' => $form->translate('Key Property'), + 'required' => true, + 'description' => $form->translate( + 'Each Array in the list must contain this property. It\'s value' + . ' will be used as the key/object property name for the row.' + ) + ]); + $form->addElement('select', 'on_duplicate', [ + 'label' => 'On duplicate key', + 'description' => $form->translate('What should we do, if the same key occurs twice?'), + 'multiOptions' => $form->optionalEnum([ + 'fail' => $form->translate('Let the whole import run fail'), + 'keep_first' => $form->translate('Keep the first row with that key'), + 'keep_last' => $form->translate('Keep the last row with that key'), + ]), + 'required' => true, + ]); + } + + public function getName() + { + return 'Transform Array/Object list into single Object'; + } + + public function hasArraySupport() + { + return true; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + if (! \is_array($value)) { + throw new InvalidArgumentException( + 'Array expected, got ' . Error::getPhpTypeName($value) + ); + } + $keyProperty = $this->getSetting('key_property'); + $onDuplicate = $this->getSetting('on_duplicate'); + $result = (object) []; + foreach ($value as $key => $row) { + if (\is_object($row)) { + $row = (array) $row; + } + if (! \is_array($row)) { + throw new InvalidArgumentException( + "List of Arrays expected expected. Array entry '$key' is " + . Error::getPhpTypeName($value) + ); + } + + if (! \array_key_exists($keyProperty, $row)) { + throw new InvalidArgumentException( + "Key property '$keyProperty' is required, but missing on row '$key'" + ); + } + + $property = $row[$keyProperty]; + if (isset($result->$property)) { + switch ($onDuplicate) { + case 'fail': + throw new InvalidArgumentException( + "Duplicate row with $keyProperty=$property found on row '$key'" + ); + case 'keep_first': + // Do nothing + break; + case 'keep_last': + $result->$property = (object) $row; + break; + } + } else { + $result->$property = (object) $row; + } + } + + return $result; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierLowercase.php b/library/Director/PropertyModifier/PropertyModifierLowercase.php new file mode 100644 index 0000000..1fdbb4d --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierLowercase.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierLowercase extends PropertyModifierHook +{ + public function transform($value) + { + if ($value === null) { + return null; + } + + return \mb_strtolower($value, 'UTF-8'); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php b/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php new file mode 100644 index 0000000..ed91bcf --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierMakeBoolean extends PropertyModifierHook +{ + protected static $validStrings = array( + '0' => false, + 'false' => false, + 'n' => false, + 'no' => false, + '1' => true, + 'true' => true, + 'y' => true, + 'yes' => true, + ); + + public function getName() + { + return 'Convert to a boolean value'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'on_invalid', array( + 'label' => 'Invalid properties', + 'required' => true, + 'description' => $form->translate( + 'This modifier transforms 0/"0"/false/"false"/"n"/"no" to false' + . ' and 1, "1", true, "true", "y" and "yes" to true, both in a' + . ' case insensitive way. What should happen if the given value' + . ' does not match any of those?' + . ' You could return a null value, or default to false or true.' + . ' You might also consider interrupting the whole import process' + . ' as of invalid source data' + ), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set null'), + 'true' => $form->translate('Set true'), + 'false' => $form->translate('Set false'), + 'fail' => $form->translate('Let the import fail'), + )), + )); + } + + public function transform($value) + { + if ($value === false || $value === true || $value === null) { + return $value; + } + + if ($value === 0) { + return false; + } + + if ($value === 1) { + return true; + } + + if (is_string($value)) { + $value = strtolower($value); + + if (array_key_exists($value, self::$validStrings)) { + return self::$validStrings[$value]; + } + } + + switch ($this->getSetting('on_invalid')) { + case 'null': + return null; + + case 'false': + return false; + + case 'true': + return true; + + case 'fail': + default: + throw new InvalidPropertyException( + '"%s" cannot be converted to a boolean value', + $value + ); + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierMap.php b/library/Director/PropertyModifier/PropertyModifierMap.php new file mode 100644 index 0000000..a6cb422 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierMap.php @@ -0,0 +1,97 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierMap extends PropertyModifierHook +{ + private $cache; + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'datalist_id', array( + 'label' => 'Lookup list', + 'required' => true, + 'description' => $form->translate( + 'Please choose a data list that can be used for map lookups' + ), + 'multiOptions' => $form->optionalEnum($form->getDb()->enumDatalist()), + )); + + $form->addElement('select', 'on_missing', array( + 'label' => 'Missing entries', + 'required' => true, + 'description' => $form->translate( + 'What should happen if the lookup key does not exist in the data list?' + . ' You could return a null value, keep the unmodified imported value' + . ' or interrupt the import process' + ), + 'multiOptions' => $form->optionalEnum(array( + 'null' => $form->translate('Set null'), + 'keep' => $form->translate('Return lookup key unmodified'), + 'fail' => $form->translate('Let the import fail'), + )), + )); + + // TODO: ignore case + } + + public function transform($value) + { + $this->loadCache(); + if (array_key_exists($value, $this->cache)) { + return $this->cache[$value]; + } + + switch ($this->getSetting('on_missing')) { + case 'null': + return null; + + case 'keep': + return $value; + + case 'fail': + default: + throw new InvalidPropertyException( + '"%s" cannot be found in the "%s" data list', + $value, + $this->getDatalistName() + ); + } + } + + protected function getDatalistName() + { + $db = $this->getDb()->getDbAdapter(); + $query = $db->select()->from( + 'director_datalist', + 'list_name' + )->where( + 'id = ?', + $this->getSetting('datalist_id') + ); + $result = $db->fetchOne($query); + + return $result; + } + + protected function loadCache($force = false) + { + if ($this->cache === null || $force) { + $this->cache = array(); + $db = $this->getDb()->getDbAdapter(); + $select = $db->select()->from( + 'director_datalist_entry', + array('entry_name', 'entry_value') + )->where('list_id = ?', $this->getSetting('datalist_id')) + ->order('entry_value'); + + $this->cache = $db->fetchPairs($select); + } + + return $this; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php b/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php new file mode 100644 index 0000000..e60d692 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use function ipl\Stdlib\get_php_type; + +class PropertyModifierNegateBoolean extends PropertyModifierHook +{ + public function getName() + { + return 'Negate a boolean value'; + } + + public function transform($value) + { + if ($value === null) { + return true; + } + if (! is_bool($value)) { + throw new \InvalidArgumentException('Boolean expected, got ' . get_php_type($value)); + } + + return ! $value; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierParseURL.php b/library/Director/PropertyModifier/PropertyModifierParseURL.php new file mode 100644 index 0000000..ce7c81b --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierParseURL.php @@ -0,0 +1,81 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Exception\InvalidPropertyException; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierParseURL extends PropertyModifierHook +{ + + /** + * Array with possible components that can be returned from URL. + */ + protected static $components = [ + 'scheme' => PHP_URL_SCHEME, + 'host' => PHP_URL_HOST, + 'port' => PHP_URL_PORT, + 'path' => PHP_URL_PATH, + 'query' => PHP_URL_QUERY, + 'fragment' => PHP_URL_FRAGMENT, + ]; + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'url_component', [ + 'label' => $form->translate('URL component'), + 'description' => $form->translate('URL component'), + 'multiOptions' => $form->optionalEnum(static::enumComponents()), + 'required' => true, + ]); + + $form->addElement('select', 'on_failure', [ + 'label' => $form->translate('On failure'), + 'description' => $form->translate( + 'What should we do if the URL could not get parsed or component not found?' + ), + 'multiOptions' => $form->optionalEnum([ + 'null' => $form->translate('Set no value (null)'), + 'keep' => $form->translate('Keep the property as is'), + 'fail' => $form->translate('Let the whole import run fail'), + ]), + 'required' => true, + ]); + } + + protected static function enumComponents() + { + $components = array_keys(self::$components); + return array_combine($components, $components); + } + + public function getName() + { + return 'Parse a URL and return its components'; + } + + public function transform($value) + { + $component = self::$components[$this->getSetting('url_component')]; + $response = parse_url($value, $component); + + // if component not found $response will be null, false if seriously malformed URL + if ($response === null || $response === false) { + switch ($this->getSetting('on_failure')) { + case 'null': + return null; + case 'keep': + return $value; + case 'fail': + default: + throw new InvalidPropertyException( + 'Parsing URL "%s" failed.', + $value + ); + } + } + + return $response; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierRegexReplace.php b/library/Director/PropertyModifier/PropertyModifierRegexReplace.php new file mode 100644 index 0000000..59cb245 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierRegexReplace.php @@ -0,0 +1,45 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierRegexReplace extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'pattern', array( + 'label' => 'Regex pattern', + 'description' => $form->translate( + 'The pattern you want to search for. This can be a regular expression like /^www\d+\./' + ), + 'required' => true, + )); + + $form->addElement('text', 'replacement', array( + 'label' => 'Replacement', + 'description' => $form->translate( + 'The string that should be used as a preplacement' + ), + )); + } + + public function getName() + { + return 'Regular expression based replacement'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + return preg_replace( + $this->getSetting('pattern'), + $this->getSetting('replacement'), + $value + ); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierRegexSplit.php b/library/Director/PropertyModifier/PropertyModifierRegexSplit.php new file mode 100644 index 0000000..829810f --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierRegexSplit.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierRegexSplit extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'pattern', array( + 'label' => $form->translate('Pattern'), + 'required' => true, + 'description' => $form->translate( + 'Regular expression pattern to split the string (e.g. /\s+/ or /[,;]/)' + ) + )); + + $form->addElement('select', 'when_empty', array( + 'label' => $form->translate('When empty'), + 'required' => true, + 'description' => $form->translate( + 'What should happen when the given string is empty?' + ), + 'value' => 'empty_array', + 'multiOptions' => $form->optionalEnum(array( + 'empty_array' => $form->translate('return an empty array'), + 'null' => $form->translate('return NULL'), + )) + )); + } + + public function transform($value) + { + if (! strlen(trim($value))) { + if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') { + return array(); + } else { + return null; + } + } + + return preg_split( + $this->getSetting('pattern'), + $value, + -1, + PREG_SPLIT_NO_EMPTY + ); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php b/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php new file mode 100644 index 0000000..1485d5d --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php @@ -0,0 +1,147 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierRejectOrSelect extends PropertyModifierHook +{ + /** @var FilterExpression */ + private $filterExpression; + + public function getName() + { + return mt('director', 'Reject or keep rows based on property value'); + } + + /** + * @inheritdoc + * @throws \Zend_Form_Exception + */ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'filter_method', [ + 'label' => $form->translate('Filter method'), + 'required' => true, + 'value' => 'wildcard', + 'multiOptions' => $form->optionalEnum([ + 'wildcard' => $form->translate('Simple match with wildcards (*)'), + 'regex' => $form->translate('Regular Expression'), + 'is_true' => $form->translate('Match boolean TRUE'), + 'is_false' => $form->translate('Match boolean FALSE'), + 'is_null' => $form->translate('Match NULL value columns'), + ]), + 'class' => 'autosubmit', + ]); + + $method = $form->getSetting('filter_method'); + switch ($method) { + case 'wildcard': + $form->addElement('text', 'filter_string', [ + 'label' => $form->translate('Filter'), + 'description' => $form->translate( + 'The string/pattern you want to search for, use wildcard' + . ' matches like www.* or *linux*' + ), + 'required' => true, + ]); + break; + case 'regex': + $form->addElement('text', 'filter_string', [ + 'label' => $form->translate('Filter'), + 'description' => $form->translate( + 'The string/pattern you want to search for, use regular' + . ' expression like /^www\d+\./' + ), + 'required' => true, + ]); + break; + } + + $form->addElement('select', 'policy', [ + 'label' => $form->translate('Policy'), + 'required' => true, + 'description' => $form->translate( + 'What should happen with the row, when this property matches the given expression?' + ), + 'value' => 'reject', + 'multiOptions' => [ + 'reject' => $form->translate('Reject the whole row'), + 'keep' => $form->translate('Keep only matching rows'), + ], + ]); + } + + public function matchesRegexp($string, $expression) + { + return preg_match($expression, $string); + } + + public function isNull($string, $expression) + { + return $string === null; + } + + public function isTrue($string, $expression) + { + return $string === true; + } + + public function isFalse($string, $expression) + { + return $string === false; + } + + public function matchesWildcard($string, $expression) + { + return $this->filterExpression->matches( + (object) ['value' => $string] + ); + } + + public function transform($value) + { + $method = $this->getSetting('filter_method'); + $filter = $this->getSetting('filter_string'); + $policy = $this->getSetting('policy'); + + switch ($method) { + case 'wildcard': + $func = 'matchesWildcard'; + $this->filterExpression = new FilterExpression('value', '=', $filter); + break; + case 'regex': + $func = 'matchesRegexp'; + break; + case 'is_null': + $func = 'isNull'; + break; + case 'is_true': + $func = 'isTrue'; + break; + case 'is_false': + $func = 'isFalse'; + break; + default: + throw new ConfigurationError( + '%s is not a valid value for an ArrayFilter filter_method', + var_export($method, 1) + ); + } + + if ($this->$func($value, $filter)) { + if ($policy === 'reject') { + $this->rejectRow(); + } + } else { + if ($policy === 'keep') { + $this->rejectRow(); + } + } + + return $value; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierRenameColumn.php b/library/Director/PropertyModifier/PropertyModifierRenameColumn.php new file mode 100644 index 0000000..12524d5 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierRenameColumn.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierRenameColumn extends PropertyModifierHook +{ + public function getName() + { + return 'Rename a Property/Column'; + } + + public function requiresRow() + { + return true; + } + + public function hasArraySupport() + { + return true; + } + + public function transform($value) + { + $row = $this->getRow(); + $property = $this->getPropertyName(); + if ($row) { + unset($row->$property); + } + // $this->rejectRow(); + return $value; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierReplace.php b/library/Director/PropertyModifier/PropertyModifierReplace.php new file mode 100644 index 0000000..54e6616 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierReplace.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierReplace extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'string', array( + 'label' => 'Search string', + 'description' => $form->translate('The string you want to search for'), + 'required' => true, + )); + + $form->addElement('text', 'replacement', array( + 'label' => 'Replacement', + 'description' => $form->translate('Your replacement string'), + )); + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + return str_replace( + $this->getSetting('string'), + $this->getSetting('replacement'), + $value + ); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierReplaceNull.php b/library/Director/PropertyModifier/PropertyModifierReplaceNull.php new file mode 100644 index 0000000..d6f9fd3 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierReplaceNull.php @@ -0,0 +1,33 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierReplaceNull extends PropertyModifierHook +{ + + public function getName() + { + return 'Replace null value with String'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'string', [ + 'label' => 'Replacement String', + 'description' => $form->translate('Your replacement string'), + 'required' => true, + ]); + } + + public function transform($value) + { + if ($value === null) { + return $this->getSetting('string'); + } else { + return $value; + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php b/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php new file mode 100644 index 0000000..6c1452f --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php @@ -0,0 +1,68 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierSimpleGroupBy extends PropertyModifierHook +{ + private $keptRows = []; + + public function getName() + { + return mt('director', 'Group by a column, aggregate others'); + } + + public function requiresRow() + { + return true; + } + + public function transform($value) + { + $row = $this->getRow(); + $aggregationColumns = preg_split( + '/\s*,\s*/', + $this->getSetting('aggregation_columns'), + -1, + PREG_SPLIT_NO_EMPTY + ); + if (isset($this->keptRows[$value])) { + foreach ($aggregationColumns as $column) { + if (isset($row->$column)) { + $this->keptRows[$value]->{$column} = array_unique(array_merge( + $this->keptRows[$value]->{$column}, + [$row->$column] + )); + sort($this->keptRows[$value]->{$column}); + } + } + $this->rejectRow(); + } else { + foreach ($aggregationColumns as $column) { + if (isset($row->$column)) { + $row->$column = [$row->$column]; + } else { + $row->$column = []; + } + } + + $this->keptRows[$value] = $row; + } + + return $value; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'aggregation_columns', [ + 'label' => $form->translate('Aggregation Columns'), + 'description' => $form->translate( + 'Comma-separated list of columns that should be aggregated (transformed into an Array).' + . ' For all other columns only the first value will be kept.' + ), + 'required' => true, + ]); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php b/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php new file mode 100644 index 0000000..bf9bd31 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierSkipDuplicates extends PropertyModifierHook +{ + private $seen = []; + + public function getName() + { + return mt('director', 'Skip row if this value appears more than once'); + } + + public function transform($value) + { + if (isset($this->seen[$value])) { + $this->rejectRow(); + } + + $this->seen[$value] = true; + + return $value; + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierSplit.php b/library/Director/PropertyModifier/PropertyModifierSplit.php new file mode 100644 index 0000000..4a6fef6 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierSplit.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierSplit extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'delimiter', array( + 'label' => $form->translate('Delimiter'), + 'required' => true, + 'description' => $form->translate( + 'One or more characters that should be used to split this string' + ) + )); + + $form->addElement('select', 'when_empty', array( + 'label' => $form->translate('When empty'), + 'required' => true, + 'description' => $form->translate( + 'What should happen when the given string is empty?' + ), + 'value' => 'empty_array', + 'multiOptions' => $form->optionalEnum(array( + 'empty_array' => $form->translate('return an empty array'), + 'null' => $form->translate('return NULL'), + )) + )); + } + + public function transform($value) + { + if (! strlen(trim($value))) { + if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') { + return array(); + } else { + return null; + } + } + + return preg_split( + '/' . preg_quote($this->getSetting('delimiter'), '/') . '/', + $value, + -1, + PREG_SPLIT_NO_EMPTY + ); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierStripDomain.php b/library/Director/PropertyModifier/PropertyModifierStripDomain.php new file mode 100644 index 0000000..34fb6ba --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierStripDomain.php @@ -0,0 +1,38 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierStripDomain extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'domain', array( + 'label' => 'Domain name', + 'description' => $form->translate('The domain name you want to be stripped'), + 'required' => true, + )); + } + + public function getName() + { + return 'Strip a domain name'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $domain = preg_quote(ltrim($this->getSetting('domain'), '.'), '/'); + + return preg_replace( + '/\.' . $domain . '$/', + '', + $value + ); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierSubstring.php b/library/Director/PropertyModifier/PropertyModifierSubstring.php new file mode 100644 index 0000000..37a2f92 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierSubstring.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierSubstring extends PropertyModifierHook +{ + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('text', 'start', array( + 'label' => 'Start', + 'required' => true, + 'description' => sprintf( + $form->translate( + 'Please see %s for detailled instructions of how start and end work' + ), + 'http://php.net/manual/en/function.substr.php' + ) + )); + + $form->addElement('text', 'length', array( + 'label' => 'End', + 'description' => sprintf( + $form->translate( + 'Please see %s for detailled instructions of how start and end work' + ), + 'http://php.net/manual/en/function.substr.php' + ) + )); + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $length = $this->getSetting('length'); + if (is_numeric($length)) { + return substr( + $value, + (int) $this->getSetting('start'), + (int) $length + ); + } else { + return substr( + $value, + (int) $this->getSetting('start') + ); + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierToInt.php b/library/Director/PropertyModifier/PropertyModifierToInt.php new file mode 100644 index 0000000..dca7db9 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierToInt.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Data\InvalidDataException; +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierToInt extends PropertyModifierHook +{ + public function getName() + { + return 'Cast a string value to an Integer'; + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + if (is_int($value)) { + return $value; + } + + if (is_string($value)) { + return (int) $value; + } + + throw new InvalidDataException('String, integer or null', $value); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierTrim.php b/library/Director/PropertyModifier/PropertyModifierTrim.php new file mode 100644 index 0000000..64a655b --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierTrim.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; +use InvalidArgumentException; + +class PropertyModifierTrim extends PropertyModifierHook +{ + const VALID_METHODS = ['trim', 'ltrim', 'rtrim']; + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'trim_method', [ + 'label' => $form->translate('Trim Method'), + 'description' => $form->translate('Where to trim the string(s)'), + 'value' => 'trim', + 'multiOptions' => $form->optionalEnum([ + 'trim' => $form->translate('Beginning and Ending'), + 'ltrim' => $form->translate('Beginning only'), + 'rtrim' => $form->translate('Ending only'), + ]), + 'required' => true, + ]); + + $form->addElement('text', 'character_mask', [ + 'label' => $form->translate('Character Mask'), + 'description' => $form->translate( + 'Specify the characters that trim should remove.' + . 'Default is: " \t\n\r\0\x0B"' + ), + ]); + } + + public function transform($value) + { + if ($value === null) { + return null; + } + + $mask = $this->getSetting('character_mask'); + $method = $this->getSetting('trim_method'); + if (in_array($method, self::VALID_METHODS)) { + if ($mask) { + return $method($value, $mask); + } else { + return $method($value); + } + } + + throw new InvalidArgumentException("'$method' is not a valid trim method"); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierURLEncode.php b/library/Director/PropertyModifier/PropertyModifierURLEncode.php new file mode 100644 index 0000000..34f3588 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierURLEncode.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierURLEncode extends PropertyModifierHook +{ + public function getName() + { + return 'URL-encode a string'; + } + + + public function transform($value) + { + return rawurlencode($value); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php b/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php new file mode 100644 index 0000000..a2fff40 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php @@ -0,0 +1,44 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Icinga\Module\Director\Web\Form\QuickForm; + +class PropertyModifierUpperCaseFirst extends PropertyModifierHook +{ + public function getName() + { + return 'Uppercase the first character of each word in a string'; + } + + public static function addSettingsFormFields(QuickForm $form) + { + $form->addElement('select', 'lowerfirst', array( + 'label' => $form->translate('Use lowercase first'), + 'required' => true, + 'description' => $form->translate( + 'Should all the other characters be lowercased first?' + ), + 'value' => 'y', + 'multiOptions' => array( + 'y' => $form->translate('Yes'), + 'n' => $form->translate('No'), + ), + )); + } + + + public function transform($value) + { + if ($value === null) { + return null; + } + + if ($this->getSetting('lowerfirst', 'y') === 'y') { + return ucwords(strtolower($value)); + } else { + return ucwords($value); + } + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierUppercase.php b/library/Director/PropertyModifier/PropertyModifierUppercase.php new file mode 100644 index 0000000..e3d3d59 --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierUppercase.php @@ -0,0 +1,17 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierUppercase extends PropertyModifierHook +{ + public function transform($value) + { + if ($value === null) { + return null; + } + + return \mb_strtoupper($value, 'UTF-8'); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php b/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php new file mode 100644 index 0000000..a1e5d9c --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; +use Ramsey\Uuid\Uuid; + +class PropertyModifierUuidBinToHex extends PropertyModifierHook +{ + public function getName() + { + return mt('director', 'UUID: from binary to hex'); + } + + public function transform($value) + { + return Uuid::fromBytes($value)->toString(); + } +} diff --git a/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php b/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php new file mode 100644 index 0000000..969068e --- /dev/null +++ b/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\PropertyModifier; + +use Icinga\Module\Director\Hook\PropertyModifierHook; + +class PropertyModifierXlsNumericIp extends PropertyModifierHook +{ + public function getName() + { + return 'Fix IP formatted as a number in MS Excel'; + } + + public function transform($value) + { + if (ctype_digit($value) && strlen($value) > 9 && strlen($value) <= 12) { + return preg_replace( + '/^(\d{1,3})(\d{3})(\d{3})(\d{3})/', + '\1.\2.\3.\4', + $value + ); + } else { + return $value; + } + } +} diff --git a/library/Director/ProvidedHook/CubeLinks.php b/library/Director/ProvidedHook/CubeLinks.php new file mode 100644 index 0000000..2cb9559 --- /dev/null +++ b/library/Director/ProvidedHook/CubeLinks.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\ProvidedHook; + +use Icinga\Data\Filter\Filter; +use Icinga\Module\Cube\Cube; +use Icinga\Module\Cube\Hook\ActionsHook; +use Icinga\Module\Cube\Ido\IdoHostStatusCube; +use Icinga\Web\View; + +class CubeLinks extends ActionsHook +{ + /** + * @inheritdoc + */ + public function prepareActionLinks(Cube $cube, View $view) + { + if (! $cube instanceof IdoHostStatusCube) { + return; + } + + $cube->finalizeInnerQuery(); + $query = $cube->innerQuery() + ->reset('columns') + ->columns(array('host' => 'o.name1')) + ->reset('group'); + + $hosts = $cube->db()->fetchCol($query); + + $count = count($hosts); + if ($count === 1) { + $url = 'director/host/edit'; + $params = array('name' => $hosts[0]); + + $title = $view->translate('Modify a host'); + $description = sprintf( + $view->translate('This allows you to modify properties for "%s"'), + $hosts[0] + ); + } else { + $params = null; + + $filter = Filter::matchAny(); + foreach ($hosts as $host) { + $filter->addFilter( + Filter::matchAny(Filter::expression('name', '=', $host)) + ); + } + + $url = 'director/hosts/edit?' . $filter->toQueryString(); + + $title = sprintf($view->translate('Modify %d hosts'), $count); + $description = $view->translate( + 'This allows you to modify properties for all chosen hosts at once' + ); + } + + $this->addActionLink( + $this->makeUrl($url, $params), + $title, + $description, + 'wrench' + ); + } +} diff --git a/library/Director/ProvidedHook/IcingaDbCubeLinks.php b/library/Director/ProvidedHook/IcingaDbCubeLinks.php new file mode 100644 index 0000000..234f61f --- /dev/null +++ b/library/Director/ProvidedHook/IcingaDbCubeLinks.php @@ -0,0 +1,66 @@ +<?php + +namespace Icinga\Module\Director\ProvidedHook; + +use Icinga\Data\Filter\Filter; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Cube\Hook\IcingaDbActionsHook; +use Icinga\Module\Cube\IcingaDb\IcingaDbCube; +use Icinga\Module\Cube\IcingaDb\IcingaDbHostStatusCube; + +class IcingaDbCubeLinks extends IcingaDbActionsHook +{ + /** + * @inheritDoc + * @param IcingaDbCube $cube + * @throws ProgrammingError + */ + public function createActionLinks(IcingaDbCube $cube) + { + if (! $cube instanceof IcingaDbHostStatusCube) { + return; + } + + $filterChain = $cube->getObjectsFilter(); + + if ($filterChain->count() === 1) { + $url = 'director/host/edit?'; + $params = ['name' => $filterChain->getIterator()->current()->getValue()]; + + $title = t('Modify a host'); + $description = sprintf( + t('This allows you to modify properties for "%s"'), + $filterChain->getIterator()->current()->getValue() + ); + } else { + $params = null; + + $urlFilter = Filter::matchAny(); + foreach ($filterChain as $filter) { + $urlFilter->addFilter( + Filter::matchAny( + Filter::expression( + 'name', + '=', + $filter->getValue() + ) + ) + ); + } + + $url = 'director/hosts/edit?' . $urlFilter->toQueryString(); + + $title = sprintf(t('Modify %d hosts'), $filterChain->count()); + $description = t( + 'This allows you to modify properties for all chosen hosts at once' + ); + } + + $this->addActionLink( + $this->makeUrl($url, $params), + $title, + $description, + 'wrench' + ); + } +} diff --git a/library/Director/ProvidedHook/Monitoring/HostActions.php b/library/Director/ProvidedHook/Monitoring/HostActions.php new file mode 100644 index 0000000..2e3fba0 --- /dev/null +++ b/library/Director/ProvidedHook/Monitoring/HostActions.php @@ -0,0 +1,73 @@ +<?php + +namespace Icinga\Module\Director\ProvidedHook\Monitoring; + +use Exception; +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Monitoring; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Util; +use Icinga\Module\Monitoring\Hook\HostActionsHook; +use Icinga\Module\Monitoring\Object\Host; +use Icinga\Web\Url; + +class HostActions extends HostActionsHook +{ + public function getActionsForHost(Host $host) + { + try { + return $this->getThem($host); + } catch (Exception $e) { + return array(); + } + } + + protected function getThem(Host $host) + { + $actions = array(); + $db = $this->db(); + if (! $db) { + return $actions; + } + $hostname = $host->host_name; + if (Util::hasPermission('director/inspect')) { + $actions[mt('director', 'Inspect')] = Url::fromPath( + 'director/inspect/object', + array('type' => 'host', 'plural' => 'hosts', 'name' => $hostname) + ); + } + + $allowEdit = false; + if (Util::hasPermission('director/hosts') && IcingaHost::exists($hostname, $db)) { + $allowEdit = true; + } + $auth = Auth::getInstance(); + if (Util::hasPermission('director/monitoring/hosts')) { + $monitoring = new Monitoring(); + if ($monitoring->isAvailable() && $monitoring->authCanEditHost($auth, $hostname)) { + $allowEdit = IcingaHost::exists($hostname, $db); + } + } + + if ($allowEdit) { + $actions[mt('director', 'Modify')] = Url::fromPath( + 'director/host/edit', + array('name' => $hostname) + ); + } + + return $actions; + } + + protected function db() + { + $resourceName = Config::module('director')->get('db', 'resource'); + if (! $resourceName) { + return false; + } + + return Db::fromResourceName($resourceName); + } +} diff --git a/library/Director/ProvidedHook/Monitoring/ServiceActions.php b/library/Director/ProvidedHook/Monitoring/ServiceActions.php new file mode 100644 index 0000000..b2e303a --- /dev/null +++ b/library/Director/ProvidedHook/Monitoring/ServiceActions.php @@ -0,0 +1,87 @@ +<?php + +namespace Icinga\Module\Director\ProvidedHook\Monitoring; + +use Exception; +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Monitoring; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Util; +use Icinga\Module\Monitoring\Hook\ServiceActionsHook; +use Icinga\Module\Monitoring\Object\Service; +use Icinga\Web\Url; + +class ServiceActions extends ServiceActionsHook +{ + public function getActionsForService(Service $service) + { + try { + return $this->getThem($service); + } catch (Exception $e) { + return []; + } + } + + /** + * @param Service $service + * @return array + * @throws \Icinga\Exception\ProgrammingError + */ + protected function getThem(Service $service) + { + $actions = []; + $db = $this->db(); + if (! $db) { + return []; + } + + $hostname = $service->host_name; + $serviceName = $service->service_description; + if (Util::hasPermission('director/inspect')) { + $actions[mt('director', 'Inspect')] = Url::fromPath('director/inspect/object', [ + 'type' => 'service', + 'plural' => 'services', + 'name' => sprintf( + '%s!%s', + $hostname, + $serviceName + ) + ]); + } + + $title = null; + if (Util::hasPermission('director/hosts')) { + $title = mt('director', 'Modify'); + } elseif (Util::hasPermission('director/monitoring/services')) { + $monitoring = new Monitoring(); + if ($monitoring->isAvailable() + && $monitoring->authCanEditService(Auth::getInstance(), $hostname, $serviceName) + ) { + $title = mt('director', 'Modify'); + } + } elseif (Util::hasPermission('director/monitoring/services-ro')) { + $title = mt('director', 'Configuration'); + } + + if ($title && IcingaHost::exists($hostname, $db)) { + $actions[$title] = Url::fromPath('director/host/findservice', [ + 'name' => $hostname, + 'service' => $serviceName + ]); + } + + return $actions; + } + + protected function db() + { + $resourceName = Config::module('director')->get('db', 'resource'); + if (! $resourceName) { + return false; + } + + return Db::fromResourceName($resourceName); + } +} diff --git a/library/Director/Repository/IcingaTemplateRepository.php b/library/Director/Repository/IcingaTemplateRepository.php new file mode 100644 index 0000000..ed3b1d0 --- /dev/null +++ b/library/Director/Repository/IcingaTemplateRepository.php @@ -0,0 +1,122 @@ +<?php + +namespace Icinga\Module\Director\Repository; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\TemplateTree; + +class IcingaTemplateRepository +{ + use RepositoryByObjectHelper; + + /** @var TemplateTree */ + protected $tree; + + protected $loadedById = []; + + /** + * @return TemplateTree + */ + public function tree() + { + if ($this->tree === null) { + $this->tree = new TemplateTree($this->type, $this->connection); + } + + return $this->tree; + } + + /** + * @param IcingaObject $object + * @param bool $recursive + * @return IcingaObject[] + * @throws \Icinga\Exception\NotFoundError + */ + public function getTemplatesFor(IcingaObject $object, $recursive = false) + { + if ($recursive) { + $ids = $this->tree()->listAncestorIdsFor($object); + } else { + $ids = $this->tree()->listParentIdsFor($object); + } + + return $this->getTemplatesForIds($ids, $object); + } + + /** + * @param array $ids + * @param IcingaObject $object + * @return IcingaObject[] + * @throws \Icinga\Exception\NotFoundError + */ + public function getTemplatesForIds(array $ids, IcingaObject $object) + { + $templates = []; + foreach ($ids as $id) { + if (! array_key_exists($id, $this->loadedById)) { + // TODO: load only missing ones at once + $this->loadedById[$id] = $object::loadWithAutoIncId( + $id, + $this->connection + ); + } + + $templates[$id] = $this->loadedById[$id]; + } + + return $templates; + } + + /** + * @param IcingaObject $object + * @param bool $recursive + * @return IcingaObject[] + * @throws \Icinga\Exception\NotFoundError + */ + public function getTemplatesIndexedByNameFor( + IcingaObject $object, + $recursive = false + ) { + $templates = []; + foreach ($this->getTemplatesFor($object, $recursive) as $template) { + $templates[$template->getObjectName()] = $template; + } + + return $templates; + } + + public function persistImportNames() + { + } + + public function storeChances(Db $db) + { + } + + public function listAllowedTemplateNames() + { + $type = $this->type; + $db = $this->connection->getDbAdapter(); + $table = 'icinga_' . $this->type; + + $query = $db->select() + ->from($table, 'object_name') + ->order('object_name'); + + if ($type !== 'command') { + $query->where('object_type = ?', 'template'); + } + + if (in_array($type, ['host', 'service'])) { + $query->where('template_choice_id IS NULL'); + } + + return $db->fetchCol($query); + } + + public static function clear() + { + static::clearInstances(); + } +} diff --git a/library/Director/Repository/RepositoryByObjectHelper.php b/library/Director/Repository/RepositoryByObjectHelper.php new file mode 100644 index 0000000..0d1dda3 --- /dev/null +++ b/library/Director/Repository/RepositoryByObjectHelper.php @@ -0,0 +1,99 @@ +<?php + +namespace Icinga\Module\Director\Repository; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use RuntimeException; + +trait RepositoryByObjectHelper +{ + protected $type; + + /** @var Db */ + protected $connection; + + /** @var Auth */ + protected static $auth; + + /** @var static[] */ + protected static $instances = []; + + protected function __construct($type, Db $connection) + { + $this->type = $type; + $this->connection = $connection; + } + + /** + * @param string $type + * @return bool + */ + public static function hasInstanceForType($type) + { + return array_key_exists($type, self::$instances); + } + + /** + * @param string $type + * @param Db $connection + * @return static + */ + public static function instanceByType($type, Db $connection) + { + if (! static::hasInstanceForType($type)) { + self::$instances[$type] = new static($type, $connection); + } + + return self::$instances[$type]; + } + + /** + * @param IcingaObject $object + * @return bool + */ + public static function hasInstanceForObject(IcingaObject $object) + { + return static::hasInstanceForType($object->getShortTableName()); + } + + /** + * @param IcingaObject $object + * @param Db|null $connection + * @return static + */ + public static function instanceByObject(IcingaObject $object, Db $connection = null) + { + if (null === $connection) { + $connection = $object->getConnection(); + } + + if (! $connection) { + throw new RuntimeException(sprintf( + 'Cannot use repository for %s "%s" as it has no DB connection', + $object->getShortTableName(), + $object->getObjectName() + )); + } + + return static::instanceByType( + $object->getShortTableName(), + $connection + ); + } + + protected static function auth() + { + if (self::$auth === null) { + self::$auth = Auth::getInstance(); + } + + return self::$auth; + } + + protected static function clearInstances() + { + self::$instances = []; + } +} diff --git a/library/Director/Resolver/CommandUsage.php b/library/Director/Resolver/CommandUsage.php new file mode 100644 index 0000000..7e3e0c5 --- /dev/null +++ b/library/Director/Resolver/CommandUsage.php @@ -0,0 +1,104 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use Icinga\Module\Director\Objects\IcingaCommand; +use InvalidArgumentException; + +class CommandUsage +{ + use TranslationHelper; + + /** @var IcingaCommand */ + protected $command; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** + * CommandUsageTable constructor. + * @param IcingaCommand $command + */ + public function __construct(IcingaCommand $command) + { + if ($command->isTemplate()) { + throw new InvalidArgumentException( + 'CommandUsageTable expects object or external_object, got a template' + ); + } + + $this->command = $command; + $this->db = $command->getDb(); + } + + /** + * @return array + */ + public function getLinks() + { + $name = $this->command->getObjectName(); + $links = []; + $map = [ + 'host' => ['check_command', 'event_command'], + 'service' => ['check_command', 'event_command'], + 'notification' => ['command'], + ]; + $types = [ + 'host' => [ + 'object' => $this->translate('%d Host(s)'), + 'template' => $this->translate('%d Host Template(s)'), + ], + 'service' => [ + 'object' => $this->translate('%d Service(s)'), + 'template' => $this->translate('%d Service Template(s)'), + 'apply' => $this->translate('%d Service Apply Rule(s)'), + ], + 'notification' => [ + 'object' => $this->translate('%d Notification(s)'), + 'template' => $this->translate('%d Notification Template(s)'), + 'apply' => $this->translate('%d Notification Apply Rule(s)'), + ], + ]; + + $urlSuffix = [ + 'object' => '', + 'template' => '/templates', + 'apply' => '/applyrules', + ]; + + foreach ($map as $type => $relations) { + $res = $this->fetchFor($type, $relations, array_keys($types[$type])); + foreach ($types[$type] as $objectType => $caption) { + if ($res->$objectType > 0) { + $suffix = $urlSuffix[$objectType]; + $links[] = Link::create( + sprintf($caption, $res->$objectType), + "director/${type}s$suffix", + ['command' => $name] + ); + } + } + } + + return $links; + } + + protected function fetchFor($table, $rels, $objectTypes) + { + $id = $this->command->getAutoincId(); + + $columns = []; + foreach ($objectTypes as $type) { + $columns[$type] = "COALESCE(SUM(CASE WHEN object_type = '$type' THEN 1 ELSE 0 END), 0)"; + } + $query = $this->db->select()->from("icinga_$table", $columns); + + foreach ($rels as $rel) { + $query->orWhere("${rel}_id = ?", $id); + } + + return $this->db->fetchRow($query); + } +} diff --git a/library/Director/Resolver/HostServiceBlacklist.php b/library/Director/Resolver/HostServiceBlacklist.php new file mode 100644 index 0000000..606855a --- /dev/null +++ b/library/Director/Resolver/HostServiceBlacklist.php @@ -0,0 +1,91 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaService; + +class HostServiceBlacklist +{ + /** @var Db */ + protected $db; + + protected $table = 'icinga_host_service_blacklist'; + + protected $mappings; + + public function __construct(Db $db) + { + $this->db = $db; + } + + protected function loadMappings() + { + $db = $this->db->getDbAdapter(); + $query = $db->select()->from(['hsb' => $this->table], [ + 'host_name' => 'h.object_name', + 'service_id' => 'hsb.service_id' + ])->join( + ['h' => 'icinga_host'], + 'hsb.host_id = h.id', + [] + ); + + $result = []; + foreach ($db->fetchAll($query) as $row) { + if (array_key_exists($row->service_id, $result)) { + $result[$row->service_id][] = $row->host_name; + } else { + $result[$row->service_id] = [$row->host_name]; + } + } + + return $result; + } + + public function preloadMappings() + { + $this->mappings = $this->loadMappings(); + + return $this; + } + + public function getBlacklistedHostnamesForService(IcingaService $service) + { + if ($this->mappings === null) { + return $this->fetchMappingsForService($service); + } else { + return $this->getPreLoadedMappingsForService($service); + } + } + + public function fetchMappingsForService(IcingaService $service) + { + if (! $service->hasBeenLoadedFromDb() || $service->get('id') === null) { + return []; + } + + $db = $this->db->getDbAdapter(); + $query = $db->select()->from(['hsb' => $this->table], [ + 'host_name' => 'h.object_name', + 'service_id' => 'hsb.service_id' + ])->join( + ['h' => 'icinga_host'], + 'hsb.host_id = h.id', + [] + )->where('hsb.service_id = ?', $service->get('id')); + + return $db->fetchCol($query); + } + + public function getPreLoadedMappingsForService(IcingaService $service) + { + if ($this->mappings !== null + && array_key_exists($service->get('id'), $this->mappings) + ) { + return $this->mappings[$service->get('id')]; + } + + return []; + } +} diff --git a/library/Director/Resolver/IcingaHostObjectResolver.php b/library/Director/Resolver/IcingaHostObjectResolver.php new file mode 100644 index 0000000..210645f --- /dev/null +++ b/library/Director/Resolver/IcingaHostObjectResolver.php @@ -0,0 +1,44 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Zend_Db_Adapter_Abstract as ZfDB; + +class IcingaHostObjectResolver extends IcingaObjectResolver +{ + /** @var ZfDB */ + protected $db; + + protected $nameMaps; + + protected $baseTable = 'icinga_host'; + + protected $ignoredProperties = [ + 'id', + 'object_type', + 'disabled', + 'has_agent', + 'master_should_connect', + 'accept_config', + 'api_key', + 'template_choice_id', + ]; + + protected $relatedTables = [ + 'check_command_id' => 'icinga_command', + 'event_command_id' => 'icinga_command', + 'check_period_id' => 'icinga_timeperiod', + 'command_endpoint_id' => 'icinga_endpoint', + 'zone_id' => 'icinga_zone', + ]; + + protected $booleans = [ + 'enable_notifications', + 'enable_active_checks', + 'enable_passive_checks', + 'enable_event_handler', + 'enable_flapping', + 'enable_perfdata', + 'volatile', + ]; +} diff --git a/library/Director/Resolver/IcingaObjectResolver.php b/library/Director/Resolver/IcingaObjectResolver.php new file mode 100644 index 0000000..540e2c2 --- /dev/null +++ b/library/Director/Resolver/IcingaObjectResolver.php @@ -0,0 +1,558 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Icinga\Application\Benchmark; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\AssignFilterHelper; +use Icinga\Module\Director\Objects\DynamicApplyMatches; +use Zend_Db_Adapter_Abstract as ZfDB; + +class IcingaObjectResolver +{ + /** @var ZfDB */ + protected $db; + + protected $nameMaps; + + protected $baseTable = 'not_configured'; + + protected $ignoredProperties = []; + + protected $relatedTables = []; + + protected $booleans = []; + + /** + * @var array[] + */ + protected $templates; + + /** + * @var array[] + */ + protected $resolvedTemplateProperties; + + /** + * @var array + */ + protected $inheritancePaths; + + protected $flatImports = []; + + protected $templateVars; + + protected $resolvedTemplateVars = []; + + protected $groupMemberShips; + + protected $resolvedGroupMemberShips; + + public function __construct(ZfDb $db) + { + // TODO: loop detection. Not critical right now, as this isn't the main resolver + Benchmark::measure('Object Resolver for ' . $this->baseTable . ' warming up'); + $this->db = $db; + // Fetch: ignore disabled? + $this->prepareNameMaps(); + $this->templates = []; + foreach ($this->fetchPlainObjects($this->baseTable, 'template') as $template) { + $id = $template->id; + $this->stripIgnoredProperties($template); + $this->stripNullProperties($template); + $this->templates[$id] = (array) $template; + } + $this->templateVars = $this->fetchTemplateVars(); + $this->inheritancePaths = $this->fetchInheritancePaths($this->baseTable, 'host_id'); + foreach ($this->inheritancePaths as $path) { + $this->getResolvedImports($path); + } + + // Using already resolved data, so this is unused right now: + // $this->groupMemberShips = $this->fetchAllGroups(); + $this->resolvedGroupMemberShips = $this->fetchAllResolvedGroups(); + + foreach ($this->inheritancePaths as $path) { + if (! isset($this->resolvedTemplateProperties[$path])) { + $properties = (object) $this->getResolvedProperties($path); + $this->replaceRelatedNames($properties); + $this->convertBooleans($properties); + $this->resolvedTemplateProperties[$path] = $properties; + $this->resolvedTemplateVars[$path] = $this->getResolvedVars($path); + } + } + + Benchmark::measure('Object Resolver for ' . $this->baseTable . ' is ready'); + + // Notes: + // default != null: + // most icinga objects: disabled => n + // Icinga(ScheduledDowntime|TimePeriod)Range: range_type => include, merge_behaviour => set + // IcingaTemplateChoice: min_required => 0, max_allowed => 1 + // IcingaZone: is_global => n + // ImportSource: import_state => unknown + // SyncRule: sync_state => unknown + } + + protected static function addUniqueMembers(&$list, $newMembers) + { + foreach (\array_reverse($newMembers) as $member) { + $pos = \array_search($member, $list); + if ($pos !== false) { + unset($list[$pos]); + } + + \array_unshift($list, $member); + } + } + + public function fetchResolvedObjects() + { + $objects = []; + $allVars = $this->fetchNonTemplateVars(); + foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) { + $id = $object->id; // id will be stripped + $objects[$id] = $this->enrichObject($object, $allVars); + } + + return $objects; + } + + public function fetchObjectsMatchingFilter(Filter $filter) + { + $filter = clone($filter); + DynamicApplyMatches::setType($this->getType()); + DynamicApplyMatches::fixFilterColumns($filter); + $helper = new AssignFilterHelper($filter); + $objects = []; + $allVars = $this->fetchNonTemplateVars(); + foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) { + $id = $object->id; // id will be stripped + $object = $this->enrichObject($object, $allVars); + if ($helper->matches($object)) { + $objects[$id] = $object; + } + } + + return $objects; + } + + protected function enrichObject($object, $allVars) + { + $id = $object->id; + $this->stripIgnoredProperties($object); + if (isset($allVars[$id])) { + $vars = $allVars[$id]; + } else { + $vars = []; + } + $vars += $this->getInheritedVarsById($id); + + // There is no merge, +/-, not yet. Unused, as we use resolved groups: + // if (isset($this->groupMemberShips[$id])) { + // $groups = $this->groupMemberShips[$id]; + // } else { + // $groups = $this->getInheritedGroupsById($id); + // } + if (isset($this->resolvedGroupMemberShips[$id])) { + $groups = $this->resolvedGroupMemberShips[$id]; + } else { + $groups = []; + } + + foreach ($this->getInheritedPropertiesById($id) as $property => $value) { + if (! isset($object->$property)) { + $object->$property = $value; + } + } + $this->replaceRelatedNames($object); + $this->convertBooleans($object); + $this->stripNullProperties($object); + if (! empty($vars)) { + $object->vars = (object) $vars; + static::flattenVars($object); + } + if (! empty($groups)) { + $object->groups = $groups; + } + + $templates = $this->getTemplateNamesById($id); + if (! empty($templates)) { + $object->templates = \array_reverse($templates); + } + + return $object; + } + + /** + * @param string $baseTable e.g. icinga_host + * @param string $relColumn e.g. host_id + * @return array + */ + protected function fetchInheritancePaths($baseTable, $relColumn) + { + if ($this->db instanceof \Zend_Db_Adapter_Pdo_Pgsql) { + $groupColumn = "ARRAY_TO_STRING(ARRAY_AGG(parent_$relColumn ORDER BY weight), ',')"; + } else { + $groupColumn = "GROUP_CONCAT(parent_$relColumn ORDER BY weight SEPARATOR ',')"; + } + $query = $this->db->select() + ->from([ + 'oi' => "${baseTable}_inheritance" + ], [ + $relColumn, + $groupColumn + ]) + ->group($relColumn) + // Ordering by length increases the possibility to have less cycles afterwards + ->order("LENGTH($groupColumn)"); + + return $this->db->fetchPairs($query); + } + + protected function getInheritedPropertiesById($objectId) + { + if (isset($this->inheritancePaths[$objectId])) { + return $this->getResolvedProperties($this->inheritancePaths[$objectId]); + } else { + return []; + } + } + + protected function getInheritedVarsById($objectId) + { + if (isset($this->inheritancePaths[$objectId])) { + return $this->getResolvedVars($this->inheritancePaths[$objectId]); + } else { + return []; + } + } + + protected function getInheritedGroupsById($objectId) + { + if (isset($this->inheritancePaths[$objectId])) { + return $this->getResolvedGroups($this->inheritancePaths[$objectId]); + } else { + return []; + } + } + + protected function getTemplateNamesByID($objectId) + { + if (isset($this->inheritancePaths[$objectId])) { + return $this->translateTemplateIdsToNames( + $this->getResolvedImports($this->inheritancePaths[$objectId]) + ); + } else { + return []; + } + } + + /** + * @param $path + * @return array[] + */ + protected function getResolvedProperties($path) + { + $result = []; + // + adds only non existing members, so let's reverse our templates + foreach ($this->getResolvedImports($path) as $templateId) { + $result += $this->templates[$templateId]; + } + unset($result['object_name']); + + return $result; + } + + protected function getResolvedVars($path) + { + $result = []; + foreach ($this->getResolvedImports($path) as $templateId) { + $result += $this->getTemplateVars($templateId); + } + + return $result; + } + + protected function getTemplateVars($templateId) + { + if (isset($this->templateVars[$templateId])) { + return $this->templateVars[$templateId]; + } else { + return []; + } + } + + protected function getResolvedGroups($path) + { + $pos = \strpos($path, ','); + if ($pos === false) { + if (isset($this->groupMemberShips[$path])) { + return $this->groupMemberShips[$path]; + } else { + return []; + } + } else { + $first = \substr($path, 0, $pos); + $parentPath = \substr($path, $pos + 1); + $currentGroups = $this->getResolvedVars($first); + + // There is no merging +/-, not yet + if (empty($currentGroups)) { + return $this->getResolvedVars($parentPath); + } else { + return $currentGroups; + } + } + } + + /** + * Hint: this ships most important (last) imports first + * + * @param $path + * @return array + */ + protected function getResolvedImports($path) + { + if (! isset($this->flatImports[$path])) { + $this->flatImports[$path] = $this->calculateFlatImports($path); + } + + return $this->flatImports[$path]; + } + + protected function calculateFlatImports($path) + { + $imports = \preg_split('/,/', $path); + $ancestors = []; + foreach ($imports as $template) { + if (isset($this->inheritancePaths[$template])) { + $this->addUniqueMembers( + $ancestors, + $this->calculateFlatImports($this->inheritancePaths[$template]) + ); + } + $this->addUniqueMembers($ancestors, [$template]); + } + + return $ancestors; + } + + protected function fetchPlainObjects($table, $objectType = null) + { + $query = $this->db->select() + ->from(['o' => $table]) + ->order('o.object_name'); + + if ($objectType !== null) { + $query->where('o.object_type = ?', $objectType); + } + + return $this->db->fetchAll($query); + } + + + /** + * @param \stdClass $object + */ + protected function replaceRelatedNames($object) + { + foreach ($this->nameMaps as $property => $map) { + if (\property_exists($object, $property)) { + // Hint: substr strips _id + if ($object->$property === null) { + $object->{\substr($property, 0, -3)} = null; + } else { + $object->{\substr($property, 0, -3)} = $map[$object->$property]; + } + unset($object->$property); + } + } + } + + protected function translateTemplateIdsToNames($ids) + { + $names = []; + foreach ($ids as $id) { + if (isset($this->templates[$id])) { + $names[] = $this->templates[$id]['object_name']; + } else { + throw new \RuntimeException("There is no template with ID $id"); + } + } + + return $names; + } + + protected function stripIgnoredProperties($object) + { + foreach ($this->ignoredProperties as $key) { + unset($object->$key); + } + } + + public function prepareNameMaps() + { + // TODO: fetch from dummy Object? How to ignore irrelevant ones like choices? + $relatedNames = []; + foreach ($this->relatedTables as $key => $relatedTable) { + $relatedNames[$key] = $this->fetchRelationMap($this->baseTable, $relatedTable, $key); + } + + $this->nameMaps = $relatedNames; + } + + protected function convertBooleans($object) + { + foreach ($this->booleans as $property) { + if (\property_exists($object, $property) && $object->$property !== null) { + // Hint: substr strips _id + $object->$property = $object->$property === 'y'; + } + } + } + + protected function stripNullProperties($object) + { + foreach (\array_keys((array) $object) as $property) { + if ($object->$property === null) { + unset($object->$property); + } + } + } + + protected function fetchRelationMap($sourceTable, $destinationTable, $property) + { + $query = $this->db->select() + ->from(['d' => $destinationTable], ['d.id', 'd.object_name']) + ->join(['o' => $sourceTable], "d.id = o.$property", []) + ->order('d.object_name'); + + return $this->db->fetchPairs($query); + } + + protected function fetchTemplateVars() + { + $query = $this->prepareVarsQuery()->where('o.object_type = ?', 'template'); + return $this->fetchAndCombineVars($query); + } + + protected function fetchNonTemplateVars() + { + $query = $this->prepareVarsQuery()->where('o.object_type != ?', 'template'); + return $this->fetchAndCombineVars($query); + } + + protected function fetchAndCombineVars($query) + { + $vars = []; + foreach ($this->db->fetchAll($query) as $var) { + $id = $var->object_id; + if (! isset($vars[$id])) { + $vars[$id] = []; + } + if ($var->format === 'json') { + $vars[$id][$var->varname] = \json_decode($var->varvalue); + } else { + $vars[$id][$var->varname] = $var->varvalue; + } + } + + return $vars; + } + + protected function fetchAllGroups() + { + $query = $this->prepareGroupsQuery(); + return $this->fetchAndCombineGroups($query); + } + + protected function fetchAllResolvedGroups() + { + $query = $this->prepareGroupsQuery(true); + return $this->fetchAndCombineGroups($query); + } + + protected function fetchAndCombineGroups($query) + { + $groups = []; + foreach ($this->db->fetchAll($query) as $group) { + $id = $group->object_id; + if (isset($groups[$id])) { + $groups[$id][$group->group_id] = $group->group_name; + } else { + $groups[$id] = [ + $group->group_id => $group->group_name + ]; + } + } + + return $groups; + } + + protected function prepareGroupsQuery($resolved = false) + { + $type = $this->getType(); + $groupsTable = $this->baseTable . 'group'; + $groupMembershipTable = "${groupsTable}_$type"; + if ($resolved) { + $groupMembershipTable .= '_resolved'; + } + $oRef = "${type}_id"; + $gRef = "${type}group_id"; + + return $this->db->select() + ->from(['gm' => $groupMembershipTable], [ + 'object_id' => $oRef, + 'group_id' => $gRef, + 'group_name' => 'g.object_name' + ]) + ->join(['g' => $groupsTable], "g.id = gm.$gRef", []) + ->order("gm.$oRef") + ->order('g.object_name'); + } + + protected function prepareVarsQuery() + { + $table = $this->baseTable . '_var'; + $ref = $this->getType() . '_id'; + return $this->db->select() + ->from(['v' => $table], [ + 'object_id' => $ref, + 'v.varname', + 'v.varvalue', + 'v.format', + // 'v.checksum', + ]) + ->join(['o' => $this->baseTable], "o.id = v.$ref", []) + ->order('o.id') + ->order('v.varname'); + } + + protected function getType() + { + return \preg_replace('/^icinga_/', '', $this->baseTable); + } + + /** + * Helper, flattens all vars of a given object + * + * The object itself will be modified, and the 'vars' property will be + * replaced with corresponding 'vars.whatever' properties + * + * @param $object + * @param string $key + */ + protected static function flattenVars(\stdClass $object, $key = 'vars') + { + if (\property_exists($object, $key)) { + foreach ($object->vars as $k => $v) { + if (\is_object($v)) { + static::flattenVars($v, $k); + } + $object->{$key . '.' . $k} = $v; + } + unset($object->$key); + } + } +} diff --git a/library/Director/Resolver/OverriddenVarsResolver.php b/library/Director/Resolver/OverriddenVarsResolver.php new file mode 100644 index 0000000..4541244 --- /dev/null +++ b/library/Director/Resolver/OverriddenVarsResolver.php @@ -0,0 +1,74 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaHost; + +class OverriddenVarsResolver +{ + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var string */ + protected $overrideVarName; + + public function __construct(Db $connection) + { + $this->overrideVarName = $connection->settings()->get('override_services_varname'); + $this->db = $connection->getDbAdapter(); + } + + public function fetchForHost(IcingaHost $host) + { + $overrides = []; + $parents = $host->listFlatResolvedImportNames(); + if (empty($parents)) { + return $overrides; + } + $query = $this->db->select() + ->from(['hv' => 'icinga_host_var'], [ + 'host_name' => 'h.object_name', + 'varvalue' => 'hv.varvalue', + ]) + ->join( + ['h' => 'icinga_host'], + 'h.id = hv.host_id', + [] + ) + ->where('hv.varname = ?', $this->overrideVarName) + ->where('h.object_name IN (?)', $parents); + + foreach ($this->db->fetchAll($query) as $row) { + if ($row->varvalue === null) { + continue; + } + foreach (Json::decode($row->varvalue) as $serviceName => $vars) { + $overrides[$serviceName][$row->host_name] = $vars; + } + } + + return $overrides; + } + + public function fetchForServiceName(IcingaHost $host, $serviceName) + { + $overrides = $this->fetchForHost($host); + if (isset($overrides[$serviceName])) { + return $overrides[$serviceName]; + } + + return []; + } + + public function fetchVarForServiceName(IcingaHost $host, $serviceName, $varName) + { + $overrides = $this->fetchForHost($host); + if (isset($overrides[$serviceName][$varName])) { + return $overrides[$serviceName][$varName]; + } + + return null; + } +} diff --git a/library/Director/Resolver/OverrideHelper.php b/library/Director/Resolver/OverrideHelper.php new file mode 100644 index 0000000..f911a4f --- /dev/null +++ b/library/Director/Resolver/OverrideHelper.php @@ -0,0 +1,38 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Icinga\Module\Director\Objects\IcingaHost; +use InvalidArgumentException; + +class OverrideHelper +{ + public static function applyOverriddenVars(IcingaHost $host, $serviceName, $properties) + { + static::assertVarsForOverrides($properties); + $current = $host->getOverriddenServiceVars($serviceName); + foreach ($properties as $key => $value) { + if ($key === 'vars') { + foreach ($value as $k => $v) { + $current->$k = $v; + } + } else { + $current->{substr($key, 5)} = $value; + } + } + $host->overrideServiceVars($serviceName, $current); + } + + public static function assertVarsForOverrides($properties) + { + if (empty($properties)) { + return; + } + + foreach ($properties as $key => $value) { + if ($key !== 'vars' && substr($key, 0, 5) !== 'vars.') { + throw new InvalidArgumentException("Only Custom Variables can be set based on Variable Overrides"); + } + } + } +} diff --git a/library/Director/Resolver/TemplateTree.php b/library/Director/Resolver/TemplateTree.php new file mode 100644 index 0000000..f8d8fed --- /dev/null +++ b/library/Director/Resolver/TemplateTree.php @@ -0,0 +1,491 @@ +<?php + +namespace Icinga\Module\Director\Resolver; + +use Icinga\Application\Benchmark; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Objects\IcingaObject; +use InvalidArgumentException; +use RuntimeException; + +class TemplateTree +{ + protected $connection; + + protected $db; + + protected $parents; + + protected $children; + + protected $rootNodes; + + protected $tree; + + protected $type; + + protected $objectMaps; + + protected $names; + + protected $templateNameToId; + + public function __construct($type, Db $connection) + { + $this->type = $type; + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function getType() + { + return $this->type; + } + + public function listParentNamesFor(IcingaObject $object) + { + $id = (int) $object->getProperty('id'); + $this->requireTree(); + + if (array_key_exists($id, $this->parents)) { + return array_values($this->parents[$id]); + } + + $this->requireObjectMaps(); + + $parents = []; + if (array_key_exists($id, $this->objectMaps)) { + foreach ($this->objectMaps[$id] as $pid) { + if (array_key_exists($pid, $this->names)) { + $parents[] = $this->names[$pid]; + } else { + throw new RuntimeException(sprintf( + 'Got invalid parent id %d for %s "%s"', + $pid, + $this->type, + $object->getObjectName() + )); + } + } + } + + return $parents; + } + + protected function loadObjectMaps() + { + $this->requireTree(); + + $map = []; + $db = $this->db; + $type = $this->type; + $table = "icinga_${type}_inheritance"; + + $query = $db->select()->from( + ['i' => $table], + [ + 'object' => "i.${type}_id", + 'parent' => "i.parent_${type}_id", + ] + )->order('i.weight'); + + foreach ($db->fetchAll($query) as $row) { + $id = (int) $row->object; + if (! array_key_exists($id, $map)) { + $map[$id] = []; + } + $map[$id][] = (int) $row->parent; + } + + $this->objectMaps = $map; + } + + public function listParentIdsFor(IcingaObject $object) + { + return array_keys($this->getParentsFor($object)); + } + + public function listAncestorIdsFor(IcingaObject $object) + { + return array_keys($this->getAncestorsFor($object)); + } + + public function listChildIdsFor(IcingaObject $object) + { + return array_keys($this->getChildrenFor($object)); + } + + public function listDescendantIdsFor(IcingaObject $object) + { + return array_keys($this->getDescendantsFor($object)); + } + + public function getParentsFor(IcingaObject $object) + { + // can not use hasBeenLoadedFromDb() when in onStore() + $id = $object->getProperty('id'); + if ($id !== null) { + return $this->getParentsById($object->getProperty('id')); + } else { + throw new RuntimeException( + 'Loading parents for unstored objects has not been implemented yet' + ); + // return $this->getParentsForUnstoredObject($object); + } + } + + public function getAncestorsFor(IcingaObject $object) + { + if ($object->hasBeenModified() + && $object->gotImports() + && $object->imports()->hasBeenModified() + ) { + return $this->getAncestorsForUnstoredObject($object); + } else { + return $this->getAncestorsById($object->getProperty('id')); + } + } + + protected function getAncestorsForUnstoredObject(IcingaObject $object) + { + $this->requireTree(); + $ancestors = []; + foreach ($object->imports() as $import) { + $name = $import->getObjectName(); + if ($import->hasBeenLoadedFromDb()) { + $pid = (int) $import->get('id'); + } else { + if (! array_key_exists($name, $this->templateNameToId)) { + continue; + } + $pid = $this->templateNameToId[$name]; + } + + $this->getAncestorsById($pid, $ancestors); + + // Hint: inheritance order matters + if (false !== ($key = array_search($name, $ancestors))) { + // Note: this used to be just unset($ancestors[$key]), and that + // broke Apply Rules inheriting from Templates with the same name + // in a way that related fields no longer showed up (#1602) + // This new if relaxes this and doesn't unset in case the name + // matches the original object name. However, I'm still unsure why + // this was required at all. + if ($name !== $object->getObjectName()) { + unset($ancestors[$key]); + } + } + $ancestors[$pid] = $name; + } + + return $ancestors; + } + + protected function requireObjectMaps() + { + if ($this->objectMaps === null) { + $this->loadObjectMaps(); + } + } + + public function getParentsById($id) + { + $this->requireTree(); + + if (array_key_exists($id, $this->parents)) { + return $this->parents[$id]; + } + + $this->requireObjectMaps(); + if (array_key_exists($id, $this->objectMaps)) { + $parents = []; + foreach ($this->objectMaps[$id] as $pid) { + $parents[$pid] = $this->names[$pid]; + } + + return $parents; + } else { + return []; + } + } + + /** + * @param $id + * @param $list + * @throws NestingError + */ + protected function assertNotInList($id, &$list) + { + if (array_key_exists($id, $list)) { + $list = array_keys($list); + array_push($list, $id); + + if (is_int($id)) { + throw new NestingError( + 'Loop detected: %s', + implode(' -> ', $this->getNamesForIds($list, true)) + ); + } else { + throw new NestingError( + 'Loop detected: %s', + implode(' -> ', $list) + ); + } + } + } + + protected function getNamesForIds($ids, $ignoreErrors = false) + { + $names = []; + foreach ($ids as $id) { + $names[] = $this->getNameForId($id, $ignoreErrors); + } + + return $names; + } + + protected function getNameForId($id, $ignoreErrors = false) + { + if (! array_key_exists($id, $this->names)) { + if ($ignoreErrors) { + return "id=$id"; + } else { + throw new InvalidArgumentException("Got no name for $id"); + } + } + + return $this->names[$id]; + } + + /** + * @param $id + * @param array $ancestors + * @param array $path + * @return array + * @throws NestingError + */ + public function getAncestorsById($id, &$ancestors = [], $path = []) + { + $path[$id] = true; + foreach ($this->getParentsById($id) as $pid => $name) { + $this->assertNotInList($pid, $path); + $path[$pid] = true; + + $this->getAncestorsById($pid, $ancestors, $path); + unset($path[$pid]); + + // Hint: inheritance order matters + if (false !== ($key = array_search($name, $ancestors))) { + unset($ancestors[$key]); + } + $ancestors[$pid] = $name; + } + unset($path[$id]); + + return $ancestors; + } + + public function getChildrenFor(IcingaObject $object) + { + // can not use hasBeenLoadedFromDb() when in onStore() + $id = $object->getProperty('id'); + if ($id !== null) { + return $this->getChildrenById($id); + } else { + throw new RuntimeException( + 'Loading children for unstored objects has not been implemented yet' + ); + // return $this->getChildrenForUnstoredObject($object); + } + } + + public function getChildrenById($id) + { + $this->requireTree(); + + if (array_key_exists($id, $this->children)) { + return $this->children[$id]; + } else { + return []; + } + } + + public function getDescendantsFor(IcingaObject $object) + { + // can not use hasBeenLoadedFromDb() when in onStore() + $id = $object->getProperty('id'); + if ($id !== null) { + return $this->getDescendantsById($id); + } else { + throw new RuntimeException( + 'Loading descendants for unstored objects has not been implemented yet' + ); + // return $this->getDescendantsForUnstoredObject($object); + } + } + + public function getDescendantsById($id, &$children = [], &$path = []) + { + $path[$id] = true; + foreach ($this->getChildrenById($id) as $pid => $name) { + $this->assertNotInList($pid, $path); + $path[$pid] = true; + $this->getDescendantsById($pid, $children, $path); + unset($path[$pid]); + $children[$pid] = $name; + } + unset($path[$id]); + + return $children; + } + + public function getTree($parentId = null) + { + if ($this->tree === null) { + $this->prepareTree(); + } + + if ($parentId === null) { + return $this->returnFullTree(); + } else { + throw new RuntimeException( + 'Partial tree fetching has not been implemented yet' + ); + // return $this->partialTree($parentId); + } + } + + protected function returnFullTree() + { + $result = $this->rootNodes; + foreach ($result as $id => &$node) { + $this->addChildrenById($id, $node); + } + + return $result; + } + + protected function addChildrenById($pid, array &$base) + { + foreach ($this->getChildrenById($pid) as $id => $name) { + $base['children'][$id] = [ + 'name' => $name, + 'children' => [] + ]; + $this->addChildrenById($id, $base['children'][$id]); + } + } + + protected function prepareTree() + { + Benchmark::measure(sprintf('Prepare "%s" Template Tree', $this->type)); + $templates = $this->fetchTemplates(); + $parents = []; + $rootNodes = []; + $children = []; + $names = []; + foreach ($templates as $row) { + $id = (int) $row->id; + $pid = (int) $row->parent_id; + $names[$id] = $row->name; + if (! array_key_exists($id, $parents)) { + $parents[$id] = []; + } + + if ($row->parent_id === null) { + $rootNodes[$id] = [ + 'name' => $row->name, + 'children' => [] + ]; + continue; + } + + $names[$pid] = $row->parent_name; + $parents[$id][$pid] = $row->parent_name; + + if (! array_key_exists($pid, $children)) { + $children[$pid] = []; + } + + $children[$pid][$id] = $row->name; + } + + $this->parents = $parents; + $this->children = $children; + $this->rootNodes = $rootNodes; + $this->names = $names; + $this->templateNameToId = array_flip($names); + Benchmark::measure(sprintf('"%s" Template Tree ready', $this->type)); + } + + public function fetchObjects() + { + //?? + } + + protected function requireTree() + { + if ($this->parents === null) { + $this->prepareTree(); + } + } + + public function fetchTemplates() + { + $db = $this->db; + $type = $this->type; + $table = "icinga_$type"; + + if ($type === 'command') { + $joinCondition = $db->quoteInto( + "p.id = i.parent_${type}_id", + 'template' + ); + } else { + $joinCondition = $db->quoteInto( + "p.id = i.parent_${type}_id AND p.object_type = ?", + 'template' + ); + } + + $query = $db->select()->from( + ['o' => $table], + [ + 'id' => 'o.id', + 'name' => 'o.object_name', + 'object_type' => 'o.object_type', + 'parent_id' => 'p.id', + 'parent_name' => 'p.object_name', + ] + )->joinLeft( + ['i' => $table . '_inheritance'], + 'o.id = i.' . $type . '_id', + [] + )->joinLeft( + ['p' => $table], + $joinCondition, + [] + )->order('o.id')->order('i.weight'); + + if ($type !== 'command') { + $query->where( + 'o.object_type = ?', + 'template' + ); + } + + return $db->fetchAll($query); + } +} + +/** + * +SELECT o.id, o.object_name AS name, o.object_type, p.id AS parent_id, + p.object_name AS parent_name FROM icinga_service AS o +RIGHT JOIN icinga_service_inheritance AS i ON o.id = i.service_id +RIGHT JOIN icinga_service AS p ON p.id = i.parent_service_id + WHERE (p.object_type = 'template') AND (o.object_type = 'template') + ORDER BY o.id ASC, i.weight ASC + + */ diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php new file mode 100644 index 0000000..7329be3 --- /dev/null +++ b/library/Director/RestApi/IcingaObjectHandler.php @@ -0,0 +1,196 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\DirectorObject\Lookup\ServiceFinder; +use Icinga\Module\Director\Exception\DuplicateKeyException; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\OverrideHelper; +use InvalidArgumentException; +use RuntimeException; + +class IcingaObjectHandler extends RequestHandler +{ + /** @var IcingaObject */ + protected $object; + + /** @var CoreApi */ + protected $api; + + public function setObject(IcingaObject $object) + { + $this->object = $object; + return $this; + } + + public function setApi(CoreApi $api) + { + $this->api = $api; + return $this; + } + + /** + * @return IcingaObject + * @throws ProgrammingError + */ + protected function requireObject() + { + if ($this->object === null) { + throw new ProgrammingError('Object is required'); + } + + return $this->object; + } + + /** + * @return IcingaObject + */ + protected function loadOptionalObject() + { + return $this->object; + } + + protected function requireJsonBody() + { + $data = json_decode($this->request->getRawBody()); + + if ($data === null) { + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Invalid JSON: %s', + $this->getLastJsonError() + ); + } + + return $data; + } + + protected function getType() + { + return $this->request->getControllerName(); + } + + protected function processApiRequest() + { + try { + $this->handleApiRequest(); + } catch (NotFoundError $e) { + $this->sendJsonError($e, 404); + return; + } catch (DuplicateKeyException $e) { + $this->sendJsonError($e, 422); + return; + } catch (Exception $e) { + $this->sendJsonError($e); + } + + if ($this->request->getActionName() !== 'index') { + throw new NotFoundError('Not found'); + } + } + + protected function handleApiRequest() + { + $request = $this->request; + $db = $this->db; + + // TODO: I hate doing this: + if ($this->request->getActionName() === 'ticket') { + $host = $this->requireObject(); + + if ($host->getResolvedProperty('has_agent') !== 'y') { + throw new NotFoundError('The host "%s" is not an agent', $host->getObjectName()); + } + + $this->sendJson($this->api->getTicket($host->getObjectName())); + + // TODO: find a better way to shut down. Currently, this avoids + // "not found" errors: + exit; + } + + switch ($request->getMethod()) { + case 'DELETE': + $object = $this->requireObject(); + $object->delete(); + $this->sendJson($object->toPlainObject(false, true)); + break; + + case 'POST': + case 'PUT': + $data = (array) $this->requireJsonBody(); + $params = $this->request->getUrl()->getParams(); + $allowsOverrides = $params->get('allowOverrides'); + $type = $this->getType(); + if ($object = $this->loadOptionalObject()) { + if ($request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->get('object_type'), + 'object_name' => $object->getObjectName() + ], $data); + $object->replaceWith(IcingaObject::createByType($type, $data, $db)); + } + $this->persistChanges($object); + $this->sendJson($object->toPlainObject(false, true)); + } elseif ($allowsOverrides && $type === 'service') { + if ($request->getMethod() === 'PUT') { + throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT'); + } + $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data); + } else { + $object = IcingaObject::createByType($type, $data, $db); + $this->persistChanges($object); + $this->sendJson($object->toPlainObject(false, true)); + } + break; + + case 'GET': + $object = $this->requireObject(); + $exporter = new Exporter($this->db); + RestApiParams::applyParamsToExporter($exporter, $this->request, $object->getShortTableName()); + $this->sendJson($exporter->export($object)); + break; + + default: + $request->getResponse()->setHttpResponseCode(400); + throw new IcingaException('Unsupported method ' . $request->getMethod()); + } + } + + protected function persistChanges(IcingaObject $object) + { + if ($object->hasBeenModified()) { + $status = $object->hasBeenLoadedFromDb() ? 200 : 201; + $object->store(); + $this->response->setHttpResponseCode($status); + } else { + $this->response->setHttpResponseCode(304); + } + } + + protected function setServiceProperties($hostname, $serviceName, $properties) + { + $host = IcingaHost::load($hostname, $this->db); + $service = ServiceFinder::find($host, $serviceName); + if ($service === false) { + throw new NotFoundError('Not found'); + } + if ($service->requiresOverrides()) { + unset($properties['host']); + OverrideHelper::applyOverriddenVars($host, $serviceName, $properties); + $this->persistChanges($host); + $this->sendJson($host->toPlainObject(false, true)); + } else { + throw new RuntimeException('Found a single service, which should have been found (and dealt with) before'); + } + } +} diff --git a/library/Director/RestApi/IcingaObjectsHandler.php b/library/Director/RestApi/IcingaObjectsHandler.php new file mode 100644 index 0000000..471987a --- /dev/null +++ b/library/Director/RestApi/IcingaObjectsHandler.php @@ -0,0 +1,144 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use gipfl\Json\JsonString; +use Icinga\Application\Benchmark; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Exporter; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Zend_Db_Select as ZfSelect; + +class IcingaObjectsHandler extends RequestHandler +{ + /** @var ObjectsTable */ + protected $table; + + public function processApiRequest() + { + try { + $this->streamJsonResult(); + } catch (Exception $e) { + $this->sendJsonError($e); + } + } + + /** + * @param ObjectsTable|ApplyRulesTable $table + * @return $this + */ + public function setTable($table) + { + $this->table = $table; + return $this; + } + + /** + * @return ObjectsTable + * @throws ProgrammingError + */ + protected function getTable() + { + if ($this->table === null) { + throw new ProgrammingError('Table is required'); + } + + return $this->table; + } + + /** + * @throws ProgrammingError + * @throws \Zend_Db_Select_Exception + * @throws \Zend_Db_Statement_Exception + */ + protected function streamJsonResult() + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->response->sendHeaders(); + $connection = $this->db; + Benchmark::measure('Ready to stream JSON result'); + $db = $connection->getDbAdapter(); + $table = $this->getTable(); + $exporter = new Exporter($connection); + $type = $table->getType(); + RestApiParams::applyParamsToExporter($exporter, $this->request, $type); + $query = $table + ->getQuery() + ->reset(ZfSelect::COLUMNS) + ->columns('*') + ->reset(ZfSelect::LIMIT_COUNT) + ->reset(ZfSelect::LIMIT_OFFSET); + if ($type === 'service' && $table instanceof ApplyRulesTable) { + $exporter->showIds(); + } + echo '{ "objects": [ '; + $cnt = 0; + $objects = []; + + $dummy = IcingaObject::createByType($type, [], $connection); + $dummy->prefetchAllRelatedTypes(); + + Benchmark::measure('Pre-fetching related objects'); + PrefetchCache::initialize($this->db); + Benchmark::measure('Ready to query'); + $stmt = $db->query($query); + $this->response->sendHeaders(); + if (! ob_get_level()) { + ob_start(); + } + + $first = true; + $flushes = 0; + while ($row = $stmt->fetch()) { + /** @var IcingaObject $object */ + if ($first) { + Benchmark::measure('Fetching first row'); + } + $object = $dummy::fromDbRow($row, $connection); + $objects[] = JsonString::encode($exporter->export($object), JSON_PRETTY_PRINT); + if ($first) { + Benchmark::measure('Got first row'); + $first = false; + } + $cnt++; + if ($cnt === 100) { + if ($flushes > 0) { + echo ', '; + } + echo implode(', ', $objects); + $cnt = 0; + $objects = []; + $flushes++; + ob_end_flush(); + ob_start(); + } + } + + if ($cnt > 0) { + if ($flushes > 0) { + echo ', '; + } + echo implode(', ', $objects); + } + + if ($this->request->getUrl()->getParams()->get('benchmark')) { + echo "],\n"; + Benchmark::measure('All done'); + echo '"benchmark_string": ' . json_encode(Benchmark::renderToText()); + } else { + echo '] '; + } + + echo "}\n"; + if (ob_get_level()) { + ob_end_flush(); + } + + // TODO: can we improve this? + exit; + } +} diff --git a/library/Director/RestApi/RequestHandler.php b/library/Director/RestApi/RequestHandler.php new file mode 100644 index 0000000..6f66889 --- /dev/null +++ b/library/Director/RestApi/RequestHandler.php @@ -0,0 +1,86 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Exception; +use gipfl\Json\JsonString; +use Icinga\Module\Director\Db; +use Icinga\Web\Request; +use Icinga\Web\Response; + +abstract class RequestHandler +{ + /** @var Request */ + protected $request; + + /** @var Response */ + protected $response; + + /** @var Db */ + protected $db; + + public function __construct(Request $request, Response $response, Db $db) + { + $this->request = $request; + $this->response = $response; + $this->db = $db; + } + + abstract protected function processApiRequest(); + + public function dispatch() + { + $this->processApiRequest(); + } + + public function sendJson($object) + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->response->sendHeaders(); + echo JsonString::encode($object, JSON_PRETTY_PRINT) . "\n"; + } + + public function sendJsonError($error, $code = null) + { + $response = $this->response; + if ($code === null) { + if ($response->getHttpResponseCode() === 200) { + $response->setHttpResponseCode(500); + } + } else { + $response->setHttpResponseCode((int) $code); + } + + if ($error instanceof Exception) { + $message = $error->getMessage(); + } else { + $message = $error; + } + + $response->sendHeaders(); + $result = ['error' => $message]; + if ($this->request->getUrl()->getParam('showStacktrace')) { + $result['trace'] = $error->getTraceAsString(); + } + $this->sendJson((object) $result); + } + + // TODO: just return json_last_error_msg() for PHP >= 5.5.0 + protected function getLastJsonError() + { + switch (json_last_error()) { + case JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded'; + case JSON_ERROR_CTRL_CHAR: + return 'Control character error, possibly incorrectly encoded'; + case JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON'; + case JSON_ERROR_SYNTAX: + return 'Syntax error'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + default: + return 'An error occured when parsing a JSON string'; + } + } +} diff --git a/library/Director/RestApi/RestApiClient.php b/library/Director/RestApi/RestApiClient.php new file mode 100644 index 0000000..2ebc4d4 --- /dev/null +++ b/library/Director/RestApi/RestApiClient.php @@ -0,0 +1,311 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Icinga\Module\Director\Core\Json; +use InvalidArgumentException; +use RuntimeException; + +class RestApiClient +{ + /** @var resource */ + private $curl; + + /** @var string HTTP or HTTPS */ + private $scheme; + + /** @var string */ + private $host; + + /** @var int */ + private $port; + + /** @var string */ + private $user; + + /** @var string */ + private $pass; + + /** @var bool */ + private $verifySslPeer = true; + + /** @var bool */ + private $verifySslHost = true; + + /** @var string */ + private $proxy; + + /** @var string */ + private $proxyType; + + /** @var string */ + private $proxyUser; + + /** @var string */ + private $proxyPass; + + /** @var array */ + private $proxyTypes = [ + 'HTTP' => CURLPROXY_HTTP, + 'SOCKS5' => CURLPROXY_SOCKS5, + ]; + + /** + * RestApiClient constructor. + * + * Please note that only the host is required, user and pass are optional + * + * @param string $host + * @param string|null $user + * @param string|null $pass + */ + public function __construct($host, $user = null, $pass = null) + { + $this->host = $host; + $this->user = $user; + $this->pass = $pass; + } + + /** + * Use a proxy + * + * @param $url + * @param string $type Either HTTP or SOCKS5 + * @return $this + */ + public function setProxy($url, $type = 'HTTP') + { + $this->proxy = $url; + if (\is_int($type)) { + $this->proxyType = $type; + } else { + $this->proxyType = $this->proxyTypes[$type]; + } + return $this; + } + + /** + * @param string $user + * @param string $pass + * @return $this + */ + public function setProxyAuth($user, $pass) + { + $this->proxyUser = $user; + $this->proxyPass = $pass; + return $this; + } + + /** + * @return string + */ + public function getScheme() + { + if ($this->scheme === null) { + return 'HTTPS'; + } else { + return $this->scheme; + } + } + + public function setScheme($scheme) + { + $scheme = \strtoupper($scheme); + if (! \in_array($scheme, ['HTTP', 'HTTPS'])) { + throw new InvalidArgumentException("Got invalid scheme: $scheme"); + } + + $this->scheme = $scheme; + return $this; + } + + /** + * @return string + */ + public function getPort() + { + if ($this->port === null) { + return $this->getScheme() === 'HTTPS' ? 443 : 80; + } else { + return $this->port; + } + } + + /** + * @param int|string|null $port + * @return $this + */ + public function setPort($port) + { + if ($port === null) { + $this->port = null; + return $this; + } + $port = (int) ($port); + if ($port < 1 || $port > 65535) { + throw new InvalidArgumentException("Got invalid port: $port"); + } + + $this->port = $port; + return $this; + } + + /** + * @return bool + */ + public function isDefaultPort() + { + return $this->port === null + || $this->getScheme() === 'HTTPS' && $this->getPort() === 443 + || $this->getScheme() === 'HTTP' && $this->getPort() === 80; + } + + /** + * @param bool $disable + * @return $this + */ + public function disableSslPeerVerification($disable = true) + { + $this->verifySslPeer = ! $disable; + return $this; + } + + /** + * @param bool $disable + * @return $this + */ + public function disableSslHostVerification($disable = true) + { + $this->verifySslHost = ! $disable; + return $this; + } + + /** + * @param string $url + * @return string + */ + public function url($url) + { + return \sprintf( + '%s://%s%s/%s', + \strtolower($this->getScheme()), + $this->host, + $this->isDefaultPort() ? '' : ':' . $this->getPort(), + ltrim($url, '/') + ); + } + + /** + * @param string $url + * @param mixed $body + * @param array $headers + * @return mixed + */ + public function get($url, $body = null, $headers = []) + { + return $this->request('get', $url, $body, $headers); + } + + /** + * @param $url + * @param null $body + * @param array $headers + * @return mixed + */ + public function post($url, $body = null, $headers = []) + { + return $this->request('post', $url, Json::encode($body), $headers); + } + + /** + * @param $method + * @param $url + * @param null $body + * @param array $headers + * @return mixed + */ + protected function request($method, $url, $body = null, $headers = []) + { + $sendHeaders = ['Host: ' . $this->host]; + foreach ($headers as $key => $val) { + $sendHeaders[] = "$key: $val"; + } + + if (! \in_array('Accept', $headers)) { + $sendHeaders[] = 'Accept: application/json'; + } + + $url = $this->url($url); + $opts = [ + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => $sendHeaders, + CURLOPT_CUSTOMREQUEST => \strtoupper($method), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + ]; + + if ($this->getScheme() === 'HTTPS') { + $opts[CURLOPT_SSL_VERIFYPEER] = $this->verifySslPeer; + $opts[CURLOPT_SSL_VERIFYHOST] = $this->verifySslHost ? 2 : 0; + } + + if ($this->user !== null) { + $opts[CURLOPT_USERPWD] = \sprintf('%s:%s', $this->user, $this->pass); + } + + if ($this->proxy) { + $opts[CURLOPT_PROXY] = $this->proxy; + $opts[CURLOPT_PROXYTYPE] = $this->proxyType; + + if ($this->proxyUser) { + $opts['CURLOPT_PROXYUSERPWD'] = \sprintf( + '%s:%s', + $this->proxyUser, + $this->proxyPass + ); + } + } + + if ($body !== null) { + $opts[CURLOPT_POSTFIELDS] = $body; + } + + $curl = $this->curl(); + \curl_setopt_array($curl, $opts); + + $res = \curl_exec($curl); + if ($res === false) { + throw new RuntimeException('CURL ERROR: ' . \curl_error($curl)); + } + + $statusCode = \curl_getinfo($curl, CURLINFO_HTTP_CODE); + if ($statusCode === 401) { + throw new RuntimeException( + 'Unable to authenticate, please check your REST API credentials' + ); + } + + if ($statusCode >= 400) { + throw new RuntimeException( + "Got $statusCode: " . \var_export($res, 1) + ); + } + + return Json::decode($res); + } + + /** + * @return resource + */ + protected function curl() + { + if ($this->curl === null) { + $this->curl = \curl_init(\sprintf('https://%s:%d', $this->host, $this->port)); + if (! $this->curl) { + throw new RuntimeException('CURL INIT ERROR: ' . \curl_error($this->curl)); + } + } + + return $this->curl; + } +} diff --git a/library/Director/RestApi/RestApiParams.php b/library/Director/RestApi/RestApiParams.php new file mode 100644 index 0000000..c237ac5 --- /dev/null +++ b/library/Director/RestApi/RestApiParams.php @@ -0,0 +1,29 @@ +<?php + +namespace Icinga\Module\Director\RestApi; + +use Icinga\Module\Director\Data\Exporter; +use Icinga\Web\Request; +use InvalidArgumentException; + +class RestApiParams +{ + public static function applyParamsToExporter(Exporter $exporter, Request $request, $shortObjectType = null) + { + $params = $request->getUrl()->getParams(); + $resolved = (bool) $params->get('resolved', false); + $withNull = $params->shift('withNull'); + if ($params->get('withServices')) { + if ($shortObjectType !== 'host') { + throw new InvalidArgumentException('withServices is available for Hosts only'); + } + $exporter->enableHostServices(); + } + $properties = $params->shift('properties'); + if ($properties !== null && strlen($properties)) { + $exporter->filterProperties(preg_split('/\s*,\s*/', $properties, -1, PREG_SPLIT_NO_EMPTY)); + } + $exporter->resolveObjects($resolved); + $exporter->showDefaults($withNull); + } +} diff --git a/library/Director/Restriction/FilterByNameRestriction.php b/library/Director/Restriction/FilterByNameRestriction.php new file mode 100644 index 0000000..8c3b256 --- /dev/null +++ b/library/Director/Restriction/FilterByNameRestriction.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Director\Restriction; + +use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer; +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Zend_Db_Select as ZfSelect; + +class FilterByNameRestriction extends ObjectRestriction +{ + protected $type; + + /** @var Filter */ + protected $filter; + + public function __construct(Db $connection, Auth $auth, $type) + { + parent::__construct($connection, $auth); + $this->setType($type); + } + + protected function setType($type) + { + $this->type = $type; + $this->setNameForType($type); + } + + protected function setNameForType($type) + { + $this->name = "director/${type}/filter-by-name"; + } + + public function allows(IcingaObject $object) + { + if (! $this->isRestricted()) { + return true; + } + + return $this->getFilter()->matches([ + (object) ['object_name' => $object->getObjectName()] + ]); + } + + public function getFilter() + { + if ($this->filter === null) { + $this->filter = MatchingFilter::forUser( + $this->auth->getUser(), + $this->name, + 'object_name' + ); + } + + return $this->filter; + } + + protected function filterQuery(ZfSelect $query, $tableAlias = 'o') + { + FilterRenderer::applyToQuery($this->getFilter(), $query); + } +} diff --git a/library/Director/Restriction/HostgroupRestriction.php b/library/Director/Restriction/HostgroupRestriction.php new file mode 100644 index 0000000..1a6792b --- /dev/null +++ b/library/Director/Restriction/HostgroupRestriction.php @@ -0,0 +1,171 @@ +<?php + +namespace Icinga\Module\Director\Restriction; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Db\IcingaObjectFilterHelper; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaHostGroup; +use Icinga\Module\Director\Objects\IcingaObject; +use Zend_Db_Select as ZfSelect; + +class HostgroupRestriction extends ObjectRestriction +{ + protected $name = 'director/filter/hostgroups'; + + public function allows(IcingaObject $object) + { + if ($object instanceof IcingaHost) { + return $this->allowsHost($object); + } elseif ($object instanceof IcingaHostGroup) { + return $this->allowsHostGroup($object); + } else { + return $this; + } + } + + protected function filterQuery(ZfSelect $query, $tableAlias = 'o') + { + $table = $this->getQueryTableByAlias($query, $tableAlias); + switch ($table) { + case 'icinga_host': + $this->filterHostsQuery($query, $tableAlias); + break; + case 'icinga_service': + // TODO: Alias is hardcoded + $this->filterHostsQuery($query, 'h'); + break; + case 'icinga_hostgroup': + $this->filterHostGroupsQuery($query, $tableAlias); + break; + // Hint: other tables are ignored, so please take care! + } + + return $query; + } + + /** + * Whether access to the given host is allowed + * + * @param IcingaHost $host + * @return bool + */ + public function allowsHost(IcingaHost $host) + { + if (! $this->isRestricted()) { + return true; + } + + // Hint: branched hosts have no id + if (! $host->hasBeenLoadedFromDb() || $host->hasModifiedGroups() || $host->get('id') === null) { + foreach ($this->listRestrictedHostgroups() as $group) { + if ($host->hasGroup($group)) { + return true; + } + } + + return false; + } + + $query = $this->db->select()->from( + ['o' => 'icinga_host'], + ['id'] + )->where('o.id = ?', $host->id); + + $this->filterHostsQuery($query); + return (int) $this->db->fetchOne($query) === (int) $host->get('id'); + } + + /** + * Whether access to the given hostgroup is allowed + * + * @param IcingaHostGroup $hostgroup + * @return bool + */ + public function allowsHostGroup(IcingaHostGroup $hostgroup) + { + if (! $this->isRestricted()) { + return true; + } + + $query = $this->db->select()->from( + ['h' => 'icinga_hostgroup'], + ['id'] + )->where('id = ?', $hostgroup->id); + + $this->filterHostGroupsQuery($query); + return (int) $this->db->fetchOne($query) === (int) $hostgroup->get('id'); + } + + /** + * Apply the restriction to the given Hosts Query + * + * We assume that the query wants to fetch hosts and that therefore the + * icinga_host table already exists in the given query, using the $tableAlias + * alias. + * + * @param ZfSelect $query + * @param string $tableAlias + */ + public function filterHostsQuery(ZfSelect $query, $tableAlias = 'o') + { + if (! $this->isRestricted()) { + return; + } + + IcingaObjectFilterHelper::filterByResolvedHostgroups( + $query, + 'host', + $this->listRestrictedHostgroups(), + $tableAlias + ); + } + + /** + * Apply the restriction to the given Hosts Query + * + * We assume that the query wants to fetch hosts and that therefore the + * icinga_host table already exists in the given query, using the $tableAlias + * alias. + * + * @param ZfSelect $query + * @param string $tableAlias + */ + protected function filterHostGroupsQuery(ZfSelect $query, $tableAlias = 'o') + { + if (! $this->isRestricted()) { + return; + } + $groups = $this->listRestrictedHostgroups(); + + if (empty($groups)) { + $query->where('(1 = 0)'); + } else { + $query->where("${tableAlias}.object_name IN (?)", $groups); + } + } + + /** + * Give a list of allowed Hostgroups + * + * When not restricted, null is returned. This might eventually also give + * an empty list, and therefore not allow any access at all + * + * @return array|null + */ + protected function listRestrictedHostgroups() + { + if ($restrictions = $this->auth->getRestrictions($this->getName())) { + $groups = array(); + foreach ($restrictions as $restriction) { + foreach ($this->gracefullySplitOnComma($restriction) as $group) { + $groups[$group] = $group; + } + } + + return array_keys($groups); + } else { + return null; + } + } +} diff --git a/library/Director/Restriction/MatchingFilter.php b/library/Director/Restriction/MatchingFilter.php new file mode 100644 index 0000000..162840c --- /dev/null +++ b/library/Director/Restriction/MatchingFilter.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Restriction; + +use Icinga\Data\Filter\Filter; +use Icinga\User; + +class MatchingFilter +{ + public static function forPatterns(array $restrictions, $columnName) + { + $filters = []; + foreach ($restrictions as $restriction) { + foreach (preg_split('/\|/', $restriction) as $pattern) { + $filters[] = Filter::expression( + $columnName, + '=', + $pattern + ); + } + } + + if (count($filters) === 1) { + return $filters[0]; + } else { + return Filter::matchAny($filters); + } + } + + public static function forUser( + User $user, + $restrictionName, + $columnName + ) { + return static::forPatterns( + $user->getRestrictions($restrictionName), + $columnName + ); + } +} diff --git a/library/Director/Restriction/ObjectRestriction.php b/library/Director/Restriction/ObjectRestriction.php new file mode 100644 index 0000000..9161ebb --- /dev/null +++ b/library/Director/Restriction/ObjectRestriction.php @@ -0,0 +1,84 @@ +<?php + +namespace Icinga\Module\Director\Restriction; + +use Icinga\Authentication\Auth; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Zend_Db_Select as ZfSelect; + +abstract class ObjectRestriction +{ + /** @var string */ + protected $name; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var Auth */ + protected $auth; + + public function __construct(Db $connection, Auth $auth) + { + $this->db = $connection->getDbAdapter(); + $this->auth = $auth; + } + + abstract public function allows(IcingaObject $object); + + /** + * Apply the restriction to the given Hosts Query + * + * We assume that the query wants to fetch hosts and that therefore the + * icinga_host table already exists in the given query, using the $tableAlias + * alias. + * + * @param ZfSelect $query + * @param string $tableAlias + */ + abstract protected function filterQuery(ZfSelect $query, $tableAlias = 'o'); + + public function applyToQuery(ZfSelect $query, $tableAlias = 'o') + { + if ($this->isRestricted()) { + $this->filterQuery($query, $tableAlias); + } + + return $query; + } + + public function getName() + { + if ($this->name === null) { + throw new ProgrammingError('ObjectRestriction has no name'); + } + + return $this->name; + } + + public function isRestricted() + { + $restrictions = $this->auth->getRestrictions($this->getName()); + return ! empty($restrictions); + } + + protected function getQueryTableByAlias(ZfSelect $query, $tableAlias) + { + $from = $query->getPart(ZfSelect::FROM); + if (! array_key_exists($tableAlias, $from)) { + throw new ProgrammingError( + 'Cannot restrict query with alias "%s", got %s', + $tableAlias, + json_encode($from) + ); + } + + return $from[$tableAlias]['tableName']; + } + + protected function gracefullySplitOnComma($string) + { + return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/library/Director/Settings.php b/library/Director/Settings.php new file mode 100644 index 0000000..d3e0987 --- /dev/null +++ b/library/Director/Settings.php @@ -0,0 +1,219 @@ +<?php + +namespace Icinga\Module\Director; + +class Settings +{ + protected $connection; + + protected $db; + + protected $cache; + + protected $defaults = [ + 'default_global_zone' => 'director-global', + 'icinga_package_name' => 'director', + 'config_format' => 'v2', + 'override_services_varname' => '_override_servicevars', + 'override_services_templatename' => 'host var overrides (Director)', + 'disable_all_jobs' => 'n', // 'y' + 'enable_audit_log' => 'n', + 'deployment_mode_v1' => 'active-passive', + 'deployment_path_v1' => null, + 'activation_script_v1' => null, + 'self-service/agent_name' => 'fqdn', + 'self-service/transform_hostname' => '0', + 'self-service/resolve_parent_host' => '0', + 'self-service/global_zones' => ['director-global'], + 'ignore_bug7530' => 'n', + 'feature_custom_endpoint' => 'n', + // 'experimental_features' => null, // 'allow' + // 'master_zone' => null, + ]; + + protected $jsonEncode = [ + 'self-service/global_zones', + 'self-service/installer_hashes', + ]; + + public function __construct(Db $connection) + { + $this->connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + /** + * @return Db + */ + public function getConnection() + { + return $this->connection; + } + + /** + * @return \Zend_Db_Adapter_Abstract + */ + public function getDb() + { + return $this->db; + } + + /** + * @param $key + * @param null $default + * @return mixed|null + */ + public function get($key, $default = null) + { + if (null === ($value = $this->getStoredValue($key, $default))) { + return $this->getDefaultValue($key); + } else { + return $value; + } + } + + /** + * @param $key + * @param null $default + * @return mixed|null + */ + public function getStoredValue($key, $default = null) + { + if (null === ($value = $this->getSetting($key))) { + return $default; + } else { + if (in_array($key, $this->jsonEncode)) { + $value = json_decode($value); + } + return $value; + } + } + + public function getDefaultValue($key) + { + if (array_key_exists($key, $this->defaults)) { + return $this->defaults[$key]; + } else { + return null; + } + } + + public function getStoredOrDefaultValue($key) + { + $value = $this->getStoredValue($key); + if ($value === null) { + return $this->getDefaultValue($key); + } else { + return $value; + } + } + + /** + * @param $name + * @param $value + * @return $this + * @throws \Zend_Db_Adapter_Exception + */ + public function set($name, $value) + { + $db = $this->db; + + if ($value === null) { + $db->delete( + 'director_setting', + $db->quoteInto('setting_name = ?', $name) + ); + + unset($this->cache[$name]); + + return $this; + } + + if (in_array($name, $this->jsonEncode)) { + $value = json_encode(array_values($value)); + } + + if ($this->getSetting($name) === $value) { + return $this; + } + + $updated = $db->update( + 'director_setting', + array('setting_value' => $value), + $db->quoteInto('setting_name = ?', $name) + ); + + if ($updated === 0) { + $db->insert( + 'director_setting', + array( + 'setting_name' => $name, + 'setting_value' => $value, + ) + ); + } + + if ($this->cache !== null) { + $this->cache[$name] = $value; + } + + return $this; + } + + public function clearCache() + { + $this->cache = null; + return $this; + } + + protected function getSetting($name, $default = null) + { + if ($this->cache === null) { + $this->refreshCache(); + } + + if (array_key_exists($name, $this->cache)) { + return $this->cache[$name]; + } + + return $default; + } + + protected function refreshCache() + { + $db = $this->db; + + $query = $db->select()->from( + array('s' => 'director_setting'), + array('setting_name', 'setting_value') + ); + + $this->cache = (array) $db->fetchPairs($query); + } + + /** + * @param $key + * @return mixed|null + */ + public function __get($key) + { + return $this->get($key); + } + + /** + * @param $key + * @param $value + * @throws \Zend_Db_Adapter_Exception + */ + public function __set($key, $value) + { + $this->set($key, $value); + } + + public function __destruct() + { + $this->clearCache(); + unset($this->db); + unset($this->connection); + } +} diff --git a/library/Director/StartupLogRenderer.php b/library/Director/StartupLogRenderer.php new file mode 100644 index 0000000..bc7b3ea --- /dev/null +++ b/library/Director/StartupLogRenderer.php @@ -0,0 +1,126 @@ +<?php + +namespace Icinga\Module\Director; + +use ipl\Html\Html; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use gipfl\IcingaWeb2\Link; +use ipl\Html\ValidHtml; + +class StartupLogRenderer implements ValidHtml +{ + /** @var DirectorDeploymentLog */ + protected $deployment; + + public function __construct(DirectorDeploymentLog $deployment) + { + $this->deployment = $deployment; + } + + public function render() + { + $deployment = $this->deployment; + $log = Html::escape($deployment->get('startup_log')); + $lines = array(); + $severity = 'information'; + $sevPattern = '/^(debug|notice|information|warning|critical)\/(\w+)/'; + $settings = new Settings($this->deployment->getConnection()); + $package = $settings->get('icinga_package_name'); + $pathPattern = '~(/[\w/]+/api/packages/' . $package . '/[^/]+/)'; + $filePatternHint = $pathPattern . '([^:]+\.conf)(: (\d+))~'; + $filePatternDetail = $pathPattern . '([^:]+\.conf)(\((\d+)\))~'; + $markPattern = null; + // len [stage] + 1 + $markReplace = ' ^'; + + foreach (preg_split('/\n/', $log) as $line) { + if (preg_match('/^\[([\d\s\:\+\-]+)\]\s/', $line, $m)) { + $time = $m[1]; + // TODO: we might use new DateTime($time) and show a special "timeAgo" + // format - but for now this should suffice. + $line = substr($line, strpos($line, ']') + 2); + } else { + $time = null; + } + + if (preg_match($sevPattern, $line, $m)) { + $severity = $m[1]; + $line = preg_replace( + $sevPattern, + '<span class="loglevel \1">\1</span>/<span class="application">\2</span>', + $line + ); + } + + if ($markPattern !== null) { + $line = preg_replace($markPattern, $markReplace, $line); + } + $line = preg_replace('/([\^]{2,})/', '<span class="error-hint">\1</span>', $line); + $markPattern = null; + + $self = $this; + if (preg_match($filePatternHint, $line, $m)) { + $line = preg_replace_callback( + $filePatternHint, + function ($matches) use ($severity, $self) { + return $self->logLink($matches, $severity); + }, + $line + ); + $line = preg_replace('/\(in/', "\n (in", $line); + $line = preg_replace('/\), new declaration/', "),\n new declaration", $line); + } elseif (preg_match($filePatternDetail, $line, $m)) { + $markIndent = strlen($m[1]); + $markPattern = '/\s{' . $markIndent . '}\^/'; + + $line = preg_replace_callback( + $filePatternDetail, + function ($matches) use ($severity, $self) { + return $self->logLink($matches, $severity); + }, + $line + ); + } + + if ($time === null) { + $lines[] .= $line; + } else { + $lines[] .= "[$time] $line"; + } + } + return implode("\n", $lines); + } + + protected function logLink($match, $severity) + { + $stageDir = $match[1]; + $filename = $match[2]; + $suffix = $match[3]; + if (preg_match('/(\d+).*/', $suffix, $m)) { + $lineNumber = $m[1]; + } else { + $lineNumber = null; + } + + $deployment = $this->deployment; + $params = array( + 'config_checksum' => $deployment->getConfigHexChecksum(), + 'deployment_id' => $deployment->get('id'), + 'file_path' => $filename, + 'backTo' => 'deployment' + ); + if ($lineNumber !== null) { + $params['highlight'] = $lineNumber; + $params['highlightSeverity'] = $severity; + } + + return Link::create( + '[stage]/' . $filename, + 'director/config/file', + $params, + [ + 'title' => $stageDir . $filename + ] + ) . $suffix; + } +} diff --git a/library/Director/Test/BaseTestCase.php b/library/Director/Test/BaseTestCase.php new file mode 100644 index 0000000..611805b --- /dev/null +++ b/library/Director/Test/BaseTestCase.php @@ -0,0 +1,127 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Application\Icinga; +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Migrations; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaZone; +use PHPUnit_Framework_TestCase; + +abstract class BaseTestCase extends PHPUnit_Framework_TestCase +{ + private static $app; + + /** @var Db */ + private static $db; + + public function setUp() + { + $this->app(); + } + + protected function skipForMissingDb() + { + if ($this->hasDb()) { + return false; + } + + $this->markTestSkipped('Test db resource has not been configured'); + + return true; + } + + protected function hasDb() + { + return $this->getDbResourceName() !== null; + } + + protected static function getDbResourceName() + { + if (array_key_exists('DIRECTOR_TESTDB_RES', $_SERVER)) { + return $_SERVER['DIRECTOR_TESTDB_RES']; + } else { + return Config::module('director')->get('testing', 'db_resource'); + } + } + + /** + * @return Db + * @throws ConfigurationError + */ + protected static function getDb() + { + if (self::$db === null) { + $resourceName = self::getDbResourceName(); + if (! $resourceName) { + throw new ConfigurationError( + 'Could not run DB-based tests, please configure a testing db resource' + ); + } + $dbConfig = ResourceFactory::getResourceConfig($resourceName); + if (array_key_exists('DIRECTOR_TESTDB', $_SERVER)) { + $dbConfig->dbname = $_SERVER['DIRECTOR_TESTDB']; + } + if (array_key_exists('DIRECTOR_TESTDB_HOST', $_SERVER)) { + $dbConfig->host = $_SERVER['DIRECTOR_TESTDB_HOST']; + } + if (array_key_exists('DIRECTOR_TESTDB_USER', $_SERVER)) { + $dbConfig->username = $_SERVER['DIRECTOR_TESTDB_USER']; + } + if (array_key_exists('DIRECTOR_TESTDB_PASSWORD', $_SERVER)) { + $dbConfig->password = $_SERVER['DIRECTOR_TESTDB_PASSWORD']; + } + self::$db = new Db($dbConfig); + $migrations = new Migrations(self::$db); + $migrations->applyPendingMigrations(); + IcingaZone::create([ + 'object_name' => 'director-global', + 'object_type' => 'external_object', + 'is_global' => 'y' + ])->store(self::$db); + } + + return self::$db; + } + + protected function newObject($type, $name, $properties = array()) + { + if (! array_key_exists('object_type', $properties)) { + $properties['object_type'] = 'object'; + } + $properties['object_name'] = $name; + + return IcingaObject::createByType($type, $properties, $this->getDb()); + } + + protected function app() + { + if (self::$app === null) { + self::$app = Icinga::app(); + } + + return self::$app; + } + + /** + * Call a protected function for a class during testing + * + * @param $obj + * @param $name + * @param array $args + * + * @return mixed + * @throws \ReflectionException + */ + public static function callMethod($obj, $name, array $args) + { + $class = new \ReflectionClass($obj); + $method = $class->getMethod($name); + $method->setAccessible(true); + return $method->invokeArgs($obj, $args); + } +} diff --git a/library/Director/Test/Bootstrap.php b/library/Director/Test/Bootstrap.php new file mode 100644 index 0000000..56bd85a --- /dev/null +++ b/library/Director/Test/Bootstrap.php @@ -0,0 +1,28 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Application\Cli; + +class Bootstrap +{ + public static function cli($basedir = null) + { + error_reporting(E_ALL | E_STRICT); + if ($basedir === null) { + $basedir = dirname(dirname(dirname(__DIR__))); + } + $testsDir = $basedir . '/test'; + require_once 'Icinga/Application/Cli.php'; + + if (array_key_exists('ICINGAWEB_CONFIGDIR', $_SERVER)) { + $configDir = $_SERVER['ICINGAWEB_CONFIGDIR']; + } else { + $configDir = $testsDir . '/config'; + } + + Cli::start($testsDir, $configDir) + ->getModuleManager() + ->loadModule('director', $basedir); + } +} diff --git a/library/Director/Test/IcingaObjectTestCase.php b/library/Director/Test/IcingaObjectTestCase.php new file mode 100644 index 0000000..a37fced --- /dev/null +++ b/library/Director/Test/IcingaObjectTestCase.php @@ -0,0 +1,92 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Module\Director\Objects\IcingaObject; + +/** + * Icinga Object test helper class + */ +abstract class IcingaObjectTestCase extends BaseTestCase +{ + protected $table; + protected $testObjectName = '___TEST___'; + + /** @var IcingaObject */ + protected $subject = null; + + protected $createdObjects = array(); + + /** + * Creates a fresh object to play with and prepares for tearDown() + * + * @param string $type table to load from + * @param string $object_name of the object + * @param array $properties + * @param bool $storeIt + * + * @return IcingaObject + */ + protected function createObject($object_name, $type = null, $properties = array(), $storeIt = true) + { + if ($type === null) { + $type = $this->table; + } + $properties['object_name'] = '___TEST___' . $type . '_' . $object_name; + $obj = IcingaObject::createByType($type, $properties, $this->getDb()); + + if ($storeIt === true) { + $obj->store(); + $this->prepareObjectTearDown($obj); + } + + return $obj; + } + + /** + * Helper method for loading an object + * + * @param string $name + * @param null $type + * @return IcingaObject + */ + protected function loadObject($name, $type = null) + { + if ($type === null) { + $type = $this->table; + } + $realName = '___TEST___' . $type . '_' . $name; + return IcingaObject::loadByType($type, $realName, $this->getDb()); + } + + /** + * Store the object in a list for deletion on tearDown() + * + * @param IcingaObject $object + * + * @return $this + */ + protected function prepareObjectTearDown(IcingaObject $object) + { + $this->assertTrue($object->hasBeenLoadedFromDb()); + $this->createdObjects[] = $object; + return $this; + } + + /** + * @inheritdoc + */ + public function tearDown() + { + if ($this->hasDb()) { + /** @var IcingaObject $object */ + foreach (array_reverse($this->createdObjects) as $object) { + $object->delete(); + } + + if ($this->subject !== null) { + $this->subject->delete(); + } + } + } +} diff --git a/library/Director/Test/ImportSourceDummy.php b/library/Director/Test/ImportSourceDummy.php new file mode 100644 index 0000000..4ac1d09 --- /dev/null +++ b/library/Director/Test/ImportSourceDummy.php @@ -0,0 +1,52 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Module\Director\Hook\ImportSourceHook; + +class ImportSourceDummy extends ImportSourceHook +{ + protected static $rows = array(); + + /** + * Returns an array containing importable objects + * + * @return array + */ + public function fetchData() + { + return self::$rows; + } + + /** + * Returns a list of all available columns + * + * @return array + */ + public function listColumns() + { + $keys = array(); + foreach (self::$rows as $row) { + $keys = array_merge($keys, array_keys($row)); + } + return $keys; + } + + public static function clearRows() + { + self::$rows = array(); + } + + public static function setRows($rows) + { + static::clearRows(); + foreach ($rows as $row) { + static::addRow($row); + } + } + + public static function addRow($row) + { + self::$rows[] = (object) $row; + } +} diff --git a/library/Director/Test/SyncTest.php b/library/Director/Test/SyncTest.php new file mode 100644 index 0000000..7614ff9 --- /dev/null +++ b/library/Director/Test/SyncTest.php @@ -0,0 +1,105 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Db\Cache\PrefetchCache; +use Icinga\Module\Director\Import\Sync; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\Objects\SyncProperty; +use Icinga\Module\Director\Objects\SyncRule; + +abstract class SyncTest extends BaseTestCase +{ + protected $objectType; + + protected $keyColumn; + + /** @var ImportSource */ + protected $source; + + /** @var SyncRule */ + protected $rule; + + /** @var SyncProperty[] */ + protected $properties = array(); + + /** @var Sync */ + protected $sync; + + public function setUp() + { + $this->source = ImportSource::create(array( + 'source_name' => 'testimport', + 'provider_class' => 'Icinga\\Module\\Director\\Test\\ImportSourceDummy', + 'key_column' => $this->keyColumn, + )); + $this->source->store($this->getDb()); + + $this->rule = SyncRule::create(array( + 'rule_name' => 'testrule', + 'object_type' => $this->objectType, + 'update_policy' => 'merge', + 'purge_existing' => 'n' + )); + $this->rule->store($this->getDb()); + + $this->sync = new Sync($this->rule); + } + + public function tearDown() + { + // properties should be deleted automatically + if ($this->rule !== null && $this->rule->hasBeenLoadedFromDb()) { + $this->rule->delete(); + } + + if ($this->source !== null && $this->source->hasBeenLoadedFromDb()) { + $this->source->delete(); + } + + // find objects created by this class and delete them + $db = $this->getDb(); + $dummy = IcingaObject::createByType($this->objectType, array(), $db); + $query = $db->getDbAdapter()->select() + ->from($dummy->getTableName()) + ->where('object_name LIKE ?', 'SYNCTEST_%'); + + /** @var IcingaObject $object */ + foreach (IcingaObject::loadAllByType($this->objectType, $db, $query) as $object) { + $object->delete(); + } + + // make sure cache is clean for other tests + PrefetchCache::forget(); + DbObject::clearAllPrefetchCaches(); + } + + /** + * @param array $rows + * + * @throws IcingaException + */ + protected function runImport($rows) + { + ImportSourceDummy::setRows($rows); + $this->source->runImport(); + if ($this->source->get('import_state') !== 'in-sync') { + throw new IcingaException('Import failed: %s', $this->source->get('last_error_message')); + } + } + + protected function setUpProperty($properties = array()) + { + $properties = array_merge(array( + 'rule_id' => $this->rule->id, + 'source_id' => $this->source->id, + 'merge_policy' => 'override', + ), $properties); + + $this->properties[] = $property = SyncProperty::create($properties); + $property->store($this->getDb()); + } +} diff --git a/library/Director/Test/TestProcess.php b/library/Director/Test/TestProcess.php new file mode 100644 index 0000000..b2399b7 --- /dev/null +++ b/library/Director/Test/TestProcess.php @@ -0,0 +1,116 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Closure; + +class TestProcess +{ + protected $command; + + protected $identifier; + + protected $exitCode; + + protected $output; + + protected $onSuccess; + + protected $onFailure; + + protected $expectedExitCode = 0; + + public function __construct($command, $identifier = null) + { + $this->command = $command; + $this->identifier = $identifier; + } + + public function getIdentifier() + { + return $this->identifier; + } + + public function expectExitCode($code) + { + $this->expectedExitCode = $code; + return $this; + } + + public function onSuccess($func) + { + $this->onSuccess = $this->makeClosure($func); + return $this; + } + + public function onFailure($func) + { + $this->onSuccess = $this->makeClosure($func); + return $this; + } + + protected function makeClosure($func) + { + if ($func instanceof Closure) { + return $func; + } + + if (is_array($func)) { + return function ($process) use ($func) { + return $func[0]->{$func[1]}($process); + }; + } + } + + public function onFailureThrow($message, $class = 'Exception') + { + return $this->onFailure(function () use ($message, $class) { + throw new $class($message); + }); + } + + public function run() + { + exec($this->command, $this->output, $this->exitCode); + + if ($this->succeeded()) { + $this->triggerSuccess(); + } else { + $this->triggerFailure(); + } + } + + public function succeeded() + { + return $this->exitCode === $this->expectedExitCode; + } + + public function failed() + { + return $this->exitCode !== $this->expectedExitCode; + } + + protected function triggerSuccess() + { + if (($func = $this->onSuccess) !== null) { + $func($this); + } + } + + protected function triggerFailure() + { + if (($func = $this->onFailure) !== null) { + $func($this); + } + } + + public function getExitCode() + { + return $this->exitCode; + } + + public function getOutput() + { + return implode("\n", $this->output) . "\n"; + } +} diff --git a/library/Director/Test/TestSuite.php b/library/Director/Test/TestSuite.php new file mode 100644 index 0000000..131b974 --- /dev/null +++ b/library/Director/Test/TestSuite.php @@ -0,0 +1,68 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Application\Icinga; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +abstract class TestSuite +{ + private $basedir; + + abstract public function run(); + + public static function newTempfile() + { + return tempnam(sys_get_temp_dir(), 'DirectorTest-'); + } + + public function process($command, $identifier = null) + { + return new TestProcess($command, $identifier); + } + + protected function filesByExtension($base, $extensions) + { + $files = array(); + + if (! is_array($extensions)) { + $extensions = array($extensions); + } + + $basedir = $this->getBaseDir() . '/' . $base; + $dir = new RecursiveDirectoryIterator($basedir); + $iterator = new RecursiveIteratorIterator( + $dir, + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + if (in_array($file->getExtension(), $extensions)) { + $files[] = $file->getPathname(); + } + } + + return $files; + } + + public function getBaseDir($file = null) + { + if ($this->basedir === null) { + $this->basedir = Icinga::app() + ->getModuleManager() + ->getModule('director') + ->getBaseDir(); + } + + if ($file === null) { + return $this->basedir; + } else { + return $this->basedir . '/' . $file; + } + } +} diff --git a/library/Director/Test/TestSuiteLint.php b/library/Director/Test/TestSuiteLint.php new file mode 100644 index 0000000..41941eb --- /dev/null +++ b/library/Director/Test/TestSuiteLint.php @@ -0,0 +1,56 @@ +<?php + +namespace Icinga\Module\Director\Test; + +use Icinga\Application\Logger; + +class TestSuiteLint extends TestSuite +{ + protected $checked; + + protected $failed; + + public function run() + { + $this->checked = $this->failed = array(); + + foreach ($this->listFiles() as $file) { + $checked[] = $file; + $cmd = "php -l '$file'"; + $this->result[$file] = $this + ->process($cmd, $file) + ->onFailure(array($this, 'failedCheck')) + ->run(); + } + } + + public function failedCheck($process) + { + Logger::error($process->getOutput()); + $this->failed[] = $process->getIdentifier(); + } + + public function hasFailures() + { + return ! empty($this->failed); + } + + protected function listFiles() + { + $basedir = $this->getBaseDir(); + $files = array( + $basedir . '/run.php', + $basedir . '/configuration.php' + ); + + foreach ($this->filesByExtension('library/Director', 'php') as $file) { + $files[] = $file; + } + + foreach ($this->filesByExtension('application', array('php', 'phtml')) as $file) { + $files[] = $file; + } + + return $files; + } +} diff --git a/library/Director/Test/TestSuiteStyle.php b/library/Director/Test/TestSuiteStyle.php new file mode 100644 index 0000000..babd43c --- /dev/null +++ b/library/Director/Test/TestSuiteStyle.php @@ -0,0 +1,66 @@ +<?php + +namespace Icinga\Module\Director\Test; + +class TestSuiteStyle extends TestSuite +{ + public function run() + { + $out = static::newTempFile(); + $check = array( + 'library/Director/', + 'application/', + 'configuration.php', + 'run.php', + ); + + /* + $options = array(); + if ($this->isVerbose) { + $options[] = '-v'; + } + */ + + /* + $phpcs = exec('which phpcs'); + if (!file_exists($phpcs)) { + $this->fail( + 'PHP_CodeSniffer not found. Please install PHP_CodeSniffer to be able to run code style tests.' + ); + } + */ + + $cmd = sprintf( + "phpcs -p --standard=PSR2 --extensions=php --encoding=utf-8 -w -s --report-checkstyle=%s '%s'", + $out, + implode("' '", $check) + ); + + $proc = $this + ->process($cmd); + + // ->onFailure(array($this, 'failedCheck')) + $proc->run(); + + echo $proc->getOutput(); + + echo file_get_contents($out); + unlink($out); + // /usr/bin/phpcs --standard=PSR2 --extensions=php --encoding=utf-8 application/ + // library/Director/ --report=full + + /* + $options[] = '--log-junit'; + $options[] = $reportPath . '/phpunit_results.xml'; + $options[] = '--coverage-html'; + $options[] = $reportPath . '/php_html_coverage'; + */ + return; + + `$cmd`; + echo $cmd . "\n"; + echo $out ."\n"; + echo file_get_contents($out); + unlink($out); + } +} diff --git a/library/Director/Test/TestSuiteUnit.php b/library/Director/Test/TestSuiteUnit.php new file mode 100644 index 0000000..8156eba --- /dev/null +++ b/library/Director/Test/TestSuiteUnit.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Test; + +abstract class TestSuiteUnit +{ + public function run() + { + } + public function __construct() + { + $this->testdoxFile = $this->newTempfile(); + } + + public function __destruct() + { + if ($this->testdoxFile && file_exists($this->testdoxFile)) { + unlink($this->testDoxfile); + } + } + + public function getPhpunitCommand() + { + // return phpunit --bootstrap test/bootstrap.php --testdox-text /tmp/testdox.txt . + } +} diff --git a/library/Director/Test/Web/Form/TestDirectorObjectForm.php b/library/Director/Test/Web/Form/TestDirectorObjectForm.php new file mode 100644 index 0000000..0722e78 --- /dev/null +++ b/library/Director/Test/Web/Form/TestDirectorObjectForm.php @@ -0,0 +1,19 @@ +<?php + +namespace Icinga\Module\Director\Test\Web; + +use Icinga\Module\Director\Web\Form\DirectorObjectForm; + +class TestDirectorObjectForm extends DirectorObjectForm +{ + protected function getActionFromRequest() + { + $this->setAction('director/test/url'); + return $this; + } + + public function regenerateCsrfToken() + { + return $this; + } +} diff --git a/library/Director/TranslationDummy.php b/library/Director/TranslationDummy.php new file mode 100644 index 0000000..937cc0b --- /dev/null +++ b/library/Director/TranslationDummy.php @@ -0,0 +1,20 @@ +<?php + +namespace Icinga\Module\Director; + +use gipfl\Translation\TranslationHelper; + +class TranslationDummy +{ + use TranslationHelper; + + protected function dummyForTranslation() + { + $this->translate('Host'); + $this->translate('Service'); + $this->translate('Zone'); + $this->translate('Command'); + $this->translate('User'); + $this->translate('Notification'); + } +} diff --git a/library/Director/Util.php b/library/Director/Util.php new file mode 100644 index 0000000..22ad5fc --- /dev/null +++ b/library/Director/Util.php @@ -0,0 +1,175 @@ +<?php + +namespace Icinga\Module\Director; + +use Icinga\Authentication\Auth; +use Icinga\Data\ResourceFactory; +use Icinga\Module\Director\Web\Form\QuickForm; +use Icinga\Exception\NotImplementedError; +use Icinga\Exception\ProgrammingError; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; + +class Util +{ + protected static $auth; + + protected static $allowedResources; + + /** + * PBKDF2 - Password-Based Cryptography Specification (RFC2898) + * + * This method strictly follows examples in php.net's documentation + * comments. Hint: RFC6070 would be a good source for related tests + * + * @param string $alg Desired hash algorythm (sha1, sha256...) + * @param string $secret Shared secret, password + * @param string $salt Hash salt + * @param int $iterations How many iterations to perform. Please use at + * least 1000+. More iterations make it slower + * but more secure. + * @param int $length Desired key length + * @param bool $raw Returns the binary key if true, hex string otherwise + * + * @throws NotImplementedError when asking for an unsupported algorightm + * @throws ProgrammingError when passing invalid parameters + * + * @return string A $length byte long key, derived from secret and salt + */ + public static function pbkdf2($alg, $secret, $salt, $iterations, $length, $raw = false) + { + if (! in_array($alg, hash_algos(), true)) { + throw new NotImplementedError('No such hash algorithm found: "%s"', $alg); + } + + if ($iterations <= 0 || $length <= 0) { + throw new ProgrammingError('Positive iterations and length required'); + } + + $hashLength = strlen(hash($alg, '', true)); + $blocks = ceil($length / $hashLength); + + $out = ''; + + for ($i = 1; $i <= $blocks; $i++) { + // $i encoded as 4 bytes, big endian. + $last = $salt . pack('N', $i); + // first iteration + $last = $xorsum = hash_hmac($alg, $last, $secret, true); + // perform the other $iterations - 1 iterations + for ($j = 1; $j < $iterations; $j++) { + $xorsum ^= ($last = hash_hmac($alg, $last, $secret, true)); + } + $out .= $xorsum; + } + + if ($raw) { + return substr($out, 0, $length); + } + + return bin2hex(substr($out, 0, $length)); + } + + public static function auth() + { + if (self::$auth === null) { + self::$auth = Auth::getInstance(); + } + return self::$auth; + } + + public static function hasPermission($name) + { + return self::auth()->hasPermission($name); + } + + public static function getRestrictions($name) + { + return self::auth()->getRestrictions($name); + } + + public static function resourceIsAllowed($name) + { + if (self::$allowedResources === null) { + $restrictions = self::getRestrictions('director/resources/use'); + $list = array(); + foreach ($restrictions as $restriction) { + foreach (preg_split('/\s*,\s*/', $restriction, -1, PREG_SPLIT_NO_EMPTY) as $key) { + $list[$key] = $key; + } + } + + self::$allowedResources = $list; + } else { + $list = self::$allowedResources; + } + + if (empty($list) || array_key_exists($name, $list)) { + return true; + } + + return false; + } + + public static function enumDbResources() + { + return self::enumResources('db'); + } + + public static function enumLdapResources() + { + return self::enumResources('ldap'); + } + + protected static function enumResources($type) + { + $resources = array(); + foreach (ResourceFactory::getResourceConfigs() as $name => $resource) { + if ($resource->get('type') === $type && self::resourceIsAllowed($name)) { + $resources[$name] = $name; + } + } + + return $resources; + } + + public static function addDbResourceFormElement(QuickForm $form, $name) + { + static::addResourceFormElement($form, $name, 'db'); + } + + public static function addLdapResourceFormElement(QuickForm $form, $name) + { + static::addResourceFormElement($form, $name, 'ldap'); + } + + protected static function addResourceFormElement(QuickForm $form, $name, $type) + { + $list = self::enumResources($type); + + $form->addElement('select', $name, array( + 'label' => 'Resource name', + 'multiOptions' => $form->optionalEnum($list), + 'required' => true, + )); + + if (empty($list)) { + if (self::hasPermission('config/application/resources')) { + $form->addHtmlHint(Html::sprintf( + $form->translate('Please click %s to create new resources'), + Link::create( + $form->translate('here'), + 'config/resource', + null, + ['data-base-target' => '_main'] + ) + )); + $msg = sprintf($form->translate('No %s resource available'), $type); + } else { + $msg = $form->translate('Please ask an administrator to grant you access to resources'); + } + + $form->getElement($name)->addError($msg); + } + } +} diff --git a/library/Director/Web/ActionBar/AutomationObjectActionBar.php b/library/Director/Web/ActionBar/AutomationObjectActionBar.php new file mode 100644 index 0000000..247677f --- /dev/null +++ b/library/Director/Web/ActionBar/AutomationObjectActionBar.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Web\ActionBar; + +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\ActionBar; +use Icinga\Web\Request; + +class AutomationObjectActionBar extends ActionBar +{ + use TranslationHelper; + + /** @var Request */ + protected $request; + + protected $label; + + public function __construct(Request $request) + { + $this->request = $request; + } + + protected function assemble() + { + $request = $this->request; + $action = $request->getActionName(); + $controller = $request->getControllerName(); + $params = ['id' => $request->getParam('id')]; + $links = [ + 'index' => Link::create( + $this->translate('Overview'), + "director/$controller", + $params, + ['class' => 'icon-info'] + ), + 'edit' => Link::create( + $this->translate('Modify'), + "director/$controller/edit", + $params, + ['class' => 'icon-edit'] + ), + 'clone' => Link::create( + $this->translate('Clone'), + "director/$controller/clone", + $params, + ['class' => 'icon-paste'] + ), + /* + // TODO: enable once handled in the controller + 'export' => Link::create( + $this->translate('Download JSON'), + $this->request->getUrl()->with('format', 'json'), + null, + [ + 'data-base-target' => '_blank', + ] + ) + */ + + ]; + unset($links[$action]); + $this->add($links); + } +} diff --git a/library/Director/Web/ActionBar/ChoicesActionBar.php b/library/Director/Web/ActionBar/ChoicesActionBar.php new file mode 100644 index 0000000..7b59d2c --- /dev/null +++ b/library/Director/Web/ActionBar/ChoicesActionBar.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Web\ActionBar; + +use gipfl\IcingaWeb2\Link; + +class ChoicesActionBar extends DirectorBaseActionBar +{ + protected function assemble() + { + $type = $this->type; + $this->add( + $this->getBackToDashboardLink() + )->add( + Link::create( + $this->translate('Add'), + "director/templatechoice/$type", + ['type' => 'object'], + [ + 'title' => $this->translate('Create a new template choice'), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + } +} diff --git a/library/Director/Web/ActionBar/DirectorBaseActionBar.php b/library/Director/Web/ActionBar/DirectorBaseActionBar.php new file mode 100644 index 0000000..8612a0d --- /dev/null +++ b/library/Director/Web/ActionBar/DirectorBaseActionBar.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Web\ActionBar; + +use Icinga\Module\Director\Dashboard\Dashboard; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\ActionBar; +use gipfl\IcingaWeb2\Url; + +class DirectorBaseActionBar extends ActionBar +{ + use TranslationHelper; + + /** @var Url */ + protected $url; + + /** @var string */ + protected $type; + + public function __construct($type, Url $url) + { + $this->type = $type; + $this->url = $url; + } + + protected function getBackToDashboardLink() + { + $name = $this->getPluralBaseType(); + if (! Dashboard::exists($name)) { + return null; + } + + return Link::create( + $this->translate('back'), + 'director/dashboard', + ['name' => $name], + [ + 'title' => sprintf( + $this->translate('Go back to "%s" Dashboard'), + $this->translate(ucfirst($this->type)) + ), + 'class' => 'icon-left-big', + 'data-base-target' => '_main' + ] + ); + } + + protected function getBaseType() + { + if (substr($this->type, -5) === 'Group') { + return substr($this->type, 0, -5); + } else { + return $this->type; + } + } + + protected function getPluralType() + { + return $this->type . 's'; + } + + protected function getPluralBaseType() + { + return $this->getBaseType() . 's'; + } +} diff --git a/library/Director/Web/ActionBar/ObjectsActionBar.php b/library/Director/Web/ActionBar/ObjectsActionBar.php new file mode 100644 index 0000000..5f86949 --- /dev/null +++ b/library/Director/Web/ActionBar/ObjectsActionBar.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Web\ActionBar; + +use gipfl\IcingaWeb2\Link; + +class ObjectsActionBar extends DirectorBaseActionBar +{ + protected function assemble() + { + $type = $this->type; + $this->add( + $this->getBackToDashboardLink() + )->add( + Link::create( + $this->translate('Add'), + "director/$type/add", + ['type' => 'object'], + [ + 'title' => $this->translate('Create a new object'), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + } +} diff --git a/library/Director/Web/ActionBar/TemplateActionBar.php b/library/Director/Web/ActionBar/TemplateActionBar.php new file mode 100644 index 0000000..53e65ed --- /dev/null +++ b/library/Director/Web/ActionBar/TemplateActionBar.php @@ -0,0 +1,42 @@ +<?php + +namespace Icinga\Module\Director\Web\ActionBar; + +use gipfl\IcingaWeb2\Link; + +class TemplateActionBar extends DirectorBaseActionBar +{ + protected function assemble() + { + $type = str_replace('_', '-', $this->type); + $plType = preg_replace('/cys$/', 'cies', $type . 's'); + $renderTree = $this->url->getParam('render') === 'tree'; + $renderParams = $renderTree ? null : ['render' => 'tree']; + $this->add( + $this->getBackToDashboardLink() + )->add( + Link::create( + $this->translate('Add'), + "director/$type/add", + ['type' => 'template'], + [ + 'title' => $this->translate('Create a new Template'), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + )->add( + Link::create( + $renderTree ? $this->translate('Table') : $this->translate('Tree'), + "director/$plType/templates", + $renderParams, + [ + 'class' => 'icon-' . ($renderTree ? 'doc-text' : 'sitemap'), + 'title' => $renderTree + ? $this->translate('Switch to Tree view') + : $this->translate('Switch to Table view') + ] + ) + ); + } +} diff --git a/library/Director/Web/Controller/ActionController.php b/library/Director/Web/Controller/ActionController.php new file mode 100644 index 0000000..6282a16 --- /dev/null +++ b/library/Director/Web/Controller/ActionController.php @@ -0,0 +1,253 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Translation\StaticTranslator; +use Icinga\Application\Benchmark; +use Icinga\Data\Paginatable; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Monitoring; +use Icinga\Module\Director\Web\Controller\Extension\CoreApi; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Module\Director\Web\Controller\Extension\RestApi; +use Icinga\Module\Director\Web\Window; +use Icinga\Security\SecurityException; +use Icinga\Web\Controller; +use Icinga\Web\UrlParams; +use InvalidArgumentException; +use gipfl\IcingaWeb2\Translator; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Widget\ControlsAndContent; +use gipfl\IcingaWeb2\Controller\Extension\ControlsAndContentHelper; +use gipfl\IcingaWeb2\Zf1\SimpleViewRenderer; +use GuzzleHttp\Psr7\ServerRequest; +use Psr\Http\Message\ServerRequestInterface; + +abstract class ActionController extends Controller implements ControlsAndContent +{ + use DirectorDb; + use CoreApi; + use RestApi; + use ControlsAndContentHelper; + + protected $isApified = false; + + /** @var UrlParams Hint for IDE, somehow does not work in web */ + protected $params; + + /** @var Monitoring */ + private $monitoring; + + /** + * @throws SecurityException + * @throws \Icinga\Exception\AuthenticationException + * @throws \Icinga\Exception\NotFoundError + */ + public function init() + { + if (! $this->getRequest()->isApiRequest() + && $this->Config()->get('frontend', 'disabled', 'no') === 'yes' + ) { + throw new NotFoundError('Not found'); + } + $this->initializeTranslator(); + Benchmark::measure('Director base Controller init()'); + $this->checkForRestApiRequest(); + $this->checkDirectorPermissions(); + $this->checkSpecialDirectorPermissions(); + } + + protected function initializeTranslator() + { + StaticTranslator::set(new Translator('director')); + } + + public function getAuth() + { + return $this->Auth(); + } + + /** + * @codingStandardsIgnoreStart + * @return Window + */ + public function Window() + { + // @codingStandardsIgnoreEnd + if ($this->window === null) { + $this->window = new Window( + $this->_request->getHeader('X-Icinga-WindowId', Window::UNDEFINED) + ); + } + return $this->window; + } + + /** + * @throws SecurityException + */ + protected function checkDirectorPermissions() + { + $this->assertPermission('director/admin'); + } + + /** + * @throws SecurityException + */ + protected function checkSpecialDirectorPermissions() + { + if ($this->params->get('format') === 'sql') { + $this->assertPermission('director/showsql'); + } + } + + /** + * Assert that the current user has one of the given permission + * + * @param array $permissions Permission name list + * + * @return $this + * @throws SecurityException If the current user lacks the given permission + */ + protected function assertOneOfPermissions($permissions) + { + $auth = $this->Auth(); + + foreach ($permissions as $permission) { + if ($auth->hasPermission($permission)) { + return $this; + } + } + + throw new SecurityException( + 'Got none of the following permissions: %s', + implode(', ', $permissions) + ); + } + + /** + * @param int $interval + * @return $this + */ + public function setAutorefreshInterval($interval) + { + if (! $this->getRequest()->isApiRequest()) { + try { + parent::setAutorefreshInterval($interval); + } catch (ProgrammingError $e) { + throw new InvalidArgumentException($e->getMessage()); + } + } + + return $this; + } + + /** + * @return ServerRequestInterface + */ + protected function getServerRequest() + { + return ServerRequest::fromGlobals(); + } + + protected function applyPaginationLimits(Paginatable $paginatable, $limit = 25, $offset = null) + { + $limit = $this->params->get('limit', $limit); + $page = $this->params->get('page', $offset); + + $paginatable->limit($limit, $page > 0 ? ($page - 1) * $limit : 0); + + return $paginatable; + } + + protected function addAddLink($title, $url, $urlParams = null, $target = '_next') + { + $this->actions()->add(Link::create( + $this->translate('Add'), + $url, + $urlParams, + [ + 'class' => 'icon-plus', + 'title' => $title, + 'data-base-target' => $target + ] + )); + + return $this; + } + + protected function addBackLink($url, $urlParams = null) + { + $this->actions()->add(new Link( + $this->translate('back'), + $url, + $urlParams, + ['class' => 'icon-left-big'] + )); + + return $this; + } + + protected function sendUnsupportedMethod() + { + $method = strtoupper($this->getRequest()->getMethod()) ; + $response = $this->getResponse(); + $this->sendJsonError($response, sprintf( + 'Method %s is not supported', + $method + ), 422); // TODO: check response code + } + + /** + * @param string $permission + * @return $this + * @throws SecurityException + */ + public function assertPermission($permission) + { + parent::assertPermission($permission); + return $this; + } + + public function postDispatch() + { + Benchmark::measure('Director postDispatch'); + if ($this->view->content || $this->view->controls) { + $viewRenderer = new SimpleViewRenderer(); + $viewRenderer->replaceZendViewRenderer(); + $this->view = $viewRenderer->view; + // Hint -> $this->view->compact is the only way since v2.8.0 + if ($this->view->compact || $this->getOriginalUrl()->getParam('view') === 'compact') { + if ($this->view->controls) { + $this->controls()->getAttributes()->add('style', 'display: none;'); + } + } + } else { + $viewRenderer = null; + } + + $cType = $this->getResponse()->getHeader('Content-Type', true); + if ($this->getRequest()->isApiRequest() || ($cType !== null && $cType !== 'text/html')) { + $this->_helper->layout()->disableLayout(); + if ($viewRenderer) { + $viewRenderer->disable(); + } else { + $this->_helper->viewRenderer->setNoRender(true); + } + } + + parent::postDispatch(); // TODO: Change the autogenerated stub + } + + /** + * @return Monitoring + */ + protected function monitoring() + { + if ($this->monitoring === null) { + $this->monitoring = new Monitoring; + } + + return $this->monitoring; + } +} diff --git a/library/Director/Web/Controller/BranchHelper.php b/library/Director/Web/Controller/BranchHelper.php new file mode 100644 index 0000000..ac2a480 --- /dev/null +++ b/library/Director/Web/Controller/BranchHelper.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchStore; +use Icinga\Module\Director\Db\Branch\BranchSupport; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Widget\NotInBranchedHint; + +trait BranchHelper +{ + /** @var Branch */ + protected $branch; + + /** @var BranchStore */ + protected $branchStore; + + /** + * @return false|\Ramsey\Uuid\UuidInterface + */ + protected function getBranchUuid() + { + return $this->getBranch()->getUuid(); + } + + protected function getBranch() + { + if ($this->branch === null) { + /** @var ActionController $this */ + $this->branch = Branch::forRequest($this->getRequest(), $this->getBranchStore(), $this->Auth()); + } + + return $this->branch; + } + + /** + * @return BranchStore + */ + protected function getBranchStore() + { + if ($this->branchStore === null) { + $this->branchStore = new BranchStore($this->db()); + } + + return $this->branchStore; + } + + protected function hasBranch() + { + return $this->getBranchUuid() !== null; + } + + protected function enableStaticObjectLoader($table) + { + if (BranchSupport::existsForTableName($table)) { + IcingaObject::setDbObjectStore(new DbObjectStore($this->db(), $this->getBranch())); + } + } + + /** + * @param string $subject + * @return bool + */ + protected function showNotInBranch($subject) + { + if ($this->getBranch()->isBranch()) { + $this->content()->add(new NotInBranchedHint($subject, $this->getBranch(), $this->Auth())); + return true; + } + + return false; + } +} diff --git a/library/Director/Web/Controller/Extension/CoreApi.php b/library/Director/Web/Controller/Extension/CoreApi.php new file mode 100644 index 0000000..75cba50 --- /dev/null +++ b/library/Director/Web/Controller/Extension/CoreApi.php @@ -0,0 +1,46 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Module\Director\Objects\IcingaEndpoint; +use Icinga\Module\Director\Core\CoreApi as Api; + +trait CoreApi +{ + /** @var Api */ + private $api; + + /** + * @return Api|null + */ + public function getApiIfAvailable() + { + if ($this->api === null) { + if ($this->db()->hasDeploymentEndpoint()) { + $endpoint = $this->db()->getDeploymentEndpoint(); + $this->api = $endpoint->api(); + } + } + + return $this->api; + } + + /** + * @param string $endpointName + * @return Api + */ + public function api($endpointName = null) + { + if ($this->api === null) { + if ($endpointName === null) { + $endpoint = $this->db()->getDeploymentEndpoint(); + } else { + $endpoint = IcingaEndpoint::load($endpointName, $this->db()); + } + + $this->api = $endpoint->api(); + } + + return $this->api; + } +} diff --git a/library/Director/Web/Controller/Extension/DirectorDb.php b/library/Director/Web/Controller/Extension/DirectorDb.php new file mode 100644 index 0000000..03bec81 --- /dev/null +++ b/library/Director/Web/Controller/Extension/DirectorDb.php @@ -0,0 +1,160 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Web\Controller\ActionController; +use Icinga\Module\Director\Web\Window; +use RuntimeException; + +trait DirectorDb +{ + /** @var Db */ + private $db; + + protected function getDbResourceName() + { + if ($name = $this->getDbResourceNameFromRequest()) { + return $name; + } elseif ($name = $this->getPreferredDbResourceName()) { + return $name; + } else { + return $this->getFirstDbResourceName(); + } + } + + protected function getDbResourceNameFromRequest() + { + $param = 'dbResourceName'; + // We shouldn't access _POST and _GET. However, this trait is used + // in various places - and our Request is going to be replaced anyways. + // So, let's not over-engineer things, this is quick & dirty: + if (isset($_POST[$param])) { + $name = $_POST[$param]; + } elseif (isset($_GET[$param])) { + $name = $_GET[$param]; + } else { + return null; + } + + if (in_array($name, $this->listAllowedDbResourceNames())) { + return $name; + } else { + return null; + } + } + + protected function getPreferredDbResourceName() + { + return $this->getWindowSessionValue('db_resource'); + } + + protected function getFirstDbResourceName() + { + $names = $this->listAllowedDbResourceNames(); + if (empty($names)) { + return null; + } else { + return array_shift($names); + } + } + + protected function listAllowedDbResourceNames() + { + /** @var \Icinga\Authentication\Auth $auth */ + $auth = $this->Auth(); + + $available = $this->listAvailableDbResourceNames(); + if ($resourceNames = $auth->getRestrictions('director/db_resource')) { + $names = []; + foreach ($resourceNames as $rNames) { + foreach ($this->splitList($rNames) as $name) { + if (array_key_exists($name, $available)) { + $names[] = $name; + } + } + } + + return $names; + } else { + return $available; + } + } + + /** + * @param string $string + * @return array + */ + protected function splitList($string) + { + return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + protected function isMultiDbSetup() + { + return count($this->listAvailableDbResourceNames()) > 1; + } + + /** + * @return array + */ + protected function listAvailableDbResourceNames() + { + /** @var \Icinga\Application\Config $config */ + $config = $this->Config(); + $resources = $config->get('db', 'resources'); + if ($resources === null) { + $resource = $config->get('db', 'resource'); + if ($resource === null) { + return []; + } else { + return [$resource => $resource]; + } + } else { + $resources = $this->splitList($resources); + $resources = array_combine($resources, $resources); + // natsort doesn't work!? + ksort($resources, SORT_NATURAL); + if ($resource = $config->get('db', 'resource')) { + unset($resources[$resource]); + $resources = [$resource => $resource] + $resources; + } + + return $resources; + } + } + + protected function getWindowSessionValue($value, $default = null) + { + /** @var Window $window */ + $window = $this->Window(); + /** @var \Icinga\Web\Session\SessionNamespace $session */ + $session = $window->getSessionNamespace('director'); + + return $session->get($value, $default); + } + + /** + * + * @return Db + */ + public function db() + { + if ($this->db === null) { + $resourceName = $this->getDbResourceName(); + if ($resourceName) { + $this->db = Db::fromResourceName($resourceName); + } elseif ($this instanceof ActionController) { + if ($this->getRequest()->isApiRequest()) { + throw new RuntimeException('Icinga Director is not correctly configured'); + } else { + $this->redirectNow('director'); + } + } else { + throw new RuntimeException('Icinga Director is not correctly configured'); + } + } + + return $this->db; + } +} diff --git a/library/Director/Web/Controller/Extension/ObjectRestrictions.php b/library/Director/Web/Controller/Extension/ObjectRestrictions.php new file mode 100644 index 0000000..bedb3f1 --- /dev/null +++ b/library/Director/Web/Controller/Extension/ObjectRestrictions.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Restriction\HostgroupRestriction; +use Icinga\Module\Director\Restriction\ObjectRestriction; + +trait ObjectRestrictions +{ + /** @var ObjectRestriction[] */ + private $objectRestrictions; + + /** + * @return ObjectRestriction[] + */ + public function getObjectRestrictions() + { + if ($this->objectRestrictions === null) { + $this->objectRestrictions = $this->loadObjectRestrictions($this->db(), $this->Auth()); + } + + return $this->objectRestrictions; + } + + /** + * @return ObjectRestriction[] + */ + protected function loadObjectRestrictions(Db $db, Auth $auth) + { + return [ + new HostgroupRestriction($db, $auth) + ]; + } + + public function allowsObject(IcingaObject $object) + { + foreach ($this->getObjectRestrictions() as $restriction) { + if (! $restriction->allows($object)) { + return false; + } + } + + return true; + } +} diff --git a/library/Director/Web/Controller/Extension/RestApi.php b/library/Director/Web/Controller/Extension/RestApi.php new file mode 100644 index 0000000..3158f49 --- /dev/null +++ b/library/Director/Web/Controller/Extension/RestApi.php @@ -0,0 +1,114 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Exception\JsonException; +use Icinga\Web\Response; +use InvalidArgumentException; +use Zend_Controller_Response_Exception; + +trait RestApi +{ + protected function isApified() + { + if (property_exists($this, 'isApified')) { + return $this->isApified; + } else { + return false; + } + } + + /** + * @return bool + */ + protected function sendNotFoundForRestApi() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + $this->sendJsonError($this->getResponse(), 'Not found', 404); + return true; + } else { + return false; + } + } + + /** + * @return bool + */ + protected function sendNotFoundUnlessRestApi() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + return false; + } else { + $this->sendJsonError($this->getResponse(), 'Not found', 404); + return true; + } + } + + /** + * @throws AuthenticationException + */ + protected function assertApiPermission() + { + if (! $this->hasPermission('director/api')) { + throw new AuthenticationException('You are not allowed to access this API'); + } + } + + /** + * @throws AuthenticationException + * @throws NotFoundError + */ + protected function checkForRestApiRequest() + { + /** @var \Icinga\Web\Request $request */ + $request = $this->getRequest(); + if ($request->isApiRequest()) { + $this->assertApiPermission(); + if (! $this->isApified()) { + throw new NotFoundError('No such API endpoint found'); + } + } + } + + /** + * @param Response $response + * @param $object + */ + protected function sendJson(Response $response, $object) + { + $response->setHeader('Content-Type', 'application/json', true); + echo json_encode($object, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + } + + /** + * @param Response $response + * @param string $message + * @param int|null $code + */ + protected function sendJsonError(Response $response, $message, $code = null) + { + if ($code !== null) { + try { + $response->setHttpResponseCode((int) $code); + } catch (Zend_Controller_Response_Exception $e) { + throw new InvalidArgumentException($e->getMessage(), 0, $e); + } + } + + $this->sendJson($response, (object) ['error' => $message]); + } + + /** + * @return string + */ + protected function getLastJsonError() + { + return JsonException::getJsonErrorMessage(json_last_error()); + } +} diff --git a/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php new file mode 100644 index 0000000..bc51548 --- /dev/null +++ b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php @@ -0,0 +1,236 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller\Extension; + +use Exception; +use Icinga\Exception\IcingaException; +use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Forms\IcingaDeleteObjectForm; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Web\Request; +use Icinga\Web\Response; + +class SingleObjectApiHandler +{ + use DirectorDb; + + /** @var IcingaObject */ + private $object; + + /** @var string */ + private $type; + + /** @var Request */ + private $request; + + /** @var Response */ + private $response; + + /** @var \Icinga\Web\UrlParams */ + private $params; + + public function __construct($type, Request $request, Response $response) + { + $this->type = $type; + $this->request = $request; + $this->response = $response; + $this->params = $request->getUrl()->getParams(); + } + + public function runFailSafe() + { + try { + $this->loadObject(); + $this->run(); + } catch (NotFoundError $e) { + $this->sendJsonError($e->getMessage(), 404); + } catch (Exception $e) { + $response = $this->response; + if ($response->getHttpResponseCode() === 200) { + $response->setHttpResponseCode(500); + } + + $this->sendJsonError($e->getMessage()); + } + } + + protected function retrieveObject() + { + $this->requireObject(); + $this->sendJson( + $this->object->toPlainObject( + $this->params->shift('resolved'), + ! $this->params->shift('withNull'), + $this->params->shift('properties') + ) + ); + } + + protected function deleteObject() + { + $this->requireObject(); + $obj = $this->object->toPlainObject(false, true); + $form = new IcingaDeleteObjectForm(); + $form->setObject($this->object) + ->setRequest($this->request) + ->onSuccess(); + + $this->sendJson($obj); + } + + protected function storeObject() + { + $data = json_decode($this->request->getRawBody()); + + if ($data === null) { + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Invalid JSON: %s' . $this->request->getRawBody(), + $this->getLastJsonError() + ); + } else { + $data = (array) $data; + } + + if ($object = $this->object) { + if ($this->request->getMethod() === 'POST') { + $object->setProperties($data); + } else { + $data = array_merge([ + 'object_type' => $object->object_type, + 'object_name' => $object->object_name + ], $data); + $object->replaceWith( + IcingaObject::createByType($this->type, $data, $db) + ); + } + } else { + $object = IcingaObject::createByType($this->type, $data, $db); + } + + if ($object->hasBeenModified()) { + $status = $object->hasBeenLoadedFromDb() ? 200 : 201; + $object->store(); + $this->response->setHttpResponseCode($status); + } else { + $this->response->setHttpResponseCode(304); + } + + $this->sendJson($object->toPlainObject(false, true)); + } + + public function run() + { + switch ($this->request->getMethod()) { + case 'DELETE': + $this->deleteObject(); + break; + + case 'POST': + case 'PUT': + $this->storeObject(); + break; + + case 'GET': + $this->retrieveObject(); + break; + + default: + $this->response->setHttpResponseCode(400); + throw new IcingaException( + 'Unsupported method: %s', + $this->request->getMethod() + ); + } + } + + protected function requireObject() + { + if (! $this->object) { + $this->response->setHttpResponseCode(404); + if (! $this->params->get('name')) { + throw new NotFoundError('You need to pass a "name" parameter to access a specific object'); + } else { + throw new NotFoundError('No such object available'); + } + } + } + + // TODO: just return json_last_error_msg() for PHP >= 5.5.0 + protected function getLastJsonError() + { + switch (json_last_error()) { + case JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded'; + case JSON_ERROR_CTRL_CHAR: + return 'Control character error, possibly incorrectly encoded'; + case JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON'; + case JSON_ERROR_SYNTAX: + return 'Syntax error'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + default: + return 'An error occured when parsing a JSON string'; + } + } + + protected function sendJson($object) + { + $this->response->setHeader('Content-Type', 'application/json', true); + $this->_helper->layout()->disableLayout(); + $this->_helper->viewRenderer->setNoRender(true); + echo json_encode($object, JSON_PRETTY_PRINT) . "\n"; + } + + protected function sendJsonError($message, $code = null) + { + $response = $this->response; + + if ($code !== null) { + $response->setHttpResponseCode((int) $code); + } + + $this->sendJson((object) ['error' => $message]); + } + + protected function loadObject() + { + if ($this->object === null) { + if ($name = $this->params->get('name')) { + $this->object = IcingaObject::loadByType( + $this->type, + $name, + $this->db() + ); + + if (! $this->allowsObject($this->object)) { + $this->object = null; + throw new NotFoundError('No such object available'); + } + } elseif ($id = $this->params->get('id')) { + $this->object = IcingaObject::loadByType( + $this->type, + (int) $id, + $this->db() + ); + } elseif ($this->request->isApiRequest()) { + if ($this->request->isGet()) { + $this->response->setHttpResponseCode(422); + + throw new InvalidPropertyException( + 'Cannot load object, missing parameters' + ); + } + } + } + + return $this->object; + } + + protected function allowsObject(IcingaObject $object) + { + return true; + } +} diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php new file mode 100644 index 0000000..0c06937 --- /dev/null +++ b/library/Director/Web/Controller/ObjectController.php @@ -0,0 +1,733 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Web\Widget\Hint; +use Icinga\Exception\IcingaException; +use Icinga\Exception\InvalidPropertyException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchedObject; +use Icinga\Module\Director\Db\Branch\UuidLookup; +use Icinga\Module\Director\Deployment\DeploymentInfo; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Forms\DeploymentLinkForm; +use Icinga\Module\Director\Forms\IcingaCloneObjectForm; +use Icinga\Module\Director\Forms\IcingaObjectFieldForm; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaObjectGroup; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use Icinga\Module\Director\RestApi\IcingaObjectHandler; +use Icinga\Module\Director\Web\Controller\Extension\ObjectRestrictions; +use Icinga\Module\Director\Web\Form\DirectorObjectForm; +use Icinga\Module\Director\Web\ObjectPreview; +use Icinga\Module\Director\Web\Table\ActivityLogTable; +use Icinga\Module\Director\Web\Table\BranchActivityTable; +use Icinga\Module\Director\Web\Table\GroupMemberTable; +use Icinga\Module\Director\Web\Table\IcingaObjectDatafieldTable; +use Icinga\Module\Director\Web\Tabs\ObjectTabs; +use Icinga\Module\Director\Web\Widget\BranchedObjectHint; +use gipfl\IcingaWeb2\Link; +use ipl\Html\Html; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +abstract class ObjectController extends ActionController +{ + use ObjectRestrictions; + use BranchHelper; + + /** @var IcingaObject */ + protected $object; + + /** @var bool This controller handles REST API requests */ + protected $isApified = true; + + /** @var array Allowed object types we are allowed to edit anyways */ + protected $allowedExternals = array( + 'apiuser', + 'endpoint' + ); + + protected $type; + + /** @var string|null */ + protected $objectBaseUrl; + + public function init() + { + parent::init(); + $this->enableStaticObjectLoader($this->getTableName()); + if ($this->getRequest()->isApiRequest()) { + $this->initializeRestApi(); + } else { + $this->initializeWebRequest(); + } + } + + protected function initializeRestApi() + { + $handler = new IcingaObjectHandler($this->getRequest(), $this->getResponse(), $this->db()); + try { + $this->loadOptionalObject(); + } catch (NotFoundError $e) { + // Silently ignore the error, the handler will complain + $handler->sendJsonError($e, 404); + // TODO: nice shutdown + exit; + } + + $handler->setApi($this->api()); + if ($this->object) { + $handler->setObject($this->object); + } + $handler->dispatch(); + // Hint: also here, hard exit. There is too much magic going on. + // Letting this bubble up smoothly would be "correct", but proved + // to be too fragile. Web 2, all kinds of pre/postDispatch magic, + // different view renderers - hard exit is the only safe bet right + // now. + exit; + } + + protected function initializeWebRequest() + { + $this->loadOptionalObject(); + if ($this->getRequest()->getActionName() === 'add') { + $this->addSingleTab( + sprintf($this->translate('Add %s'), ucfirst($this->getType())), + null, + 'add' + ); + } else { + $this->tabs(new ObjectTabs( + $this->getRequest()->getControllerName(), + $this->getAuth(), + $this->object + )); + } + if ($this->object !== null) { + $this->addDeploymentLink(); + } + } + + /** + * @throws NotFoundError + */ + public function indexAction() + { + if (! $this->getRequest()->isApiRequest()) { + $this->redirectToPreviewForExternals() + ->editAction(); + } + } + + public function addAction() + { + $this->tabs()->activate('add'); + $url = sprintf('director/%ss', $this->getPluralType()); + + $imports = $this->params->get('imports'); + $form = $this->loadObjectForm() + ->presetImports($imports) + ->setSuccessUrl($url); + + if ($oType = $this->params->get('type', 'object')) { + $form->setPreferredObjectType($oType); + } + if ($oType === 'template') { + if ($this->showNotInBranch($this->translate('Creating Templates'))) { + $this->addTitle($this->translate('Create a new Template')); + return; + } + + $this->addTemplate(); + } else { + $this->addObject(); + } + $branch = $this->getBranch(); + if ($branch->isBranch() && ! $this->getRequest()->isApiRequest()) { + $this->content()->add(new BranchedObjectHint($branch, $this->Auth())); + } + + $form->handleRequest(); + $this->content()->add($form); + } + + /** + * @throws NotFoundError + */ + public function editAction() + { + $object = $this->requireObject(); + $this->tabs()->activate('modify'); + $this->addObjectTitle(); + // Hint: Service Sets are 'templates' (as long as not being assigned to a host + if ($this->getTableName() !== 'icinga_service_set' + && $object->isTemplate() + && $this->showNotInBranch($this->translate('Modifying Templates')) + ) { + return; + } + if ($object->isApplyRule() && $this->showNotInBranch($this->translate('Modifying Apply Rules'))) { + return; + } + + $this->addObjectForm($object) + ->addActionClone() + ->addActionUsage() + ->addActionBasket(); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function renderAction() + { + $this->assertTypePermission() + ->assertPermission('director/showconfig'); + $this->tabs()->activate('render'); + $preview = new ObjectPreview($this->requireObject(), $this->getRequest()); + if ($this->object->isExternal()) { + $this->addActionClone(); + } + $this->addActionBasket(); + $preview->renderTo($this); + } + + /** + * @throws NotFoundError + */ + public function cloneAction() + { + $this->assertTypePermission(); + $object = $this->requireObject(); + $this->addTitle($this->translate('Clone: %s'), $object->getObjectName()) + ->addBackToObjectLink(); + + if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Templates'))) { + return; + } + + if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Apply Rules'))) { + return; + } + + $form = IcingaCloneObjectForm::load() + ->setBranch($this->getBranch()) + ->setObject($object) + ->setObjectBaseUrl($this->getObjectBaseUrl()) + ->handleRequest(); + + if ($object->isExternal()) { + $this->tabs()->activate('render'); + } else { + $this->tabs()->activate('modify'); + } + $this->content()->add($form); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function fieldsAction() + { + $this->assertPermission('director/admin'); + $object = $this->requireObject(); + $type = $this->getType(); + + $this->addTitle( + $this->translate('Custom fields: %s'), + $object->getObjectName() + ); + $this->tabs()->activate('fields'); + if ($this->showNotInBranch($this->translate('Managing Fields'))) { + return; + } + + try { + $this->addFieldsFormAndTable($object, $type); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + protected function addFieldsFormAndTable($object, $type) + { + $form = IcingaObjectFieldForm::load() + ->setDb($this->db()) + ->setIcingaObject($object); + + if ($id = $this->params->get('field_id')) { + $form->loadObject([ + "${type}_id" => $object->id, + 'datafield_id' => $id + ]); + + $this->actions()->add(Link::create( + $this->translate('back'), + $this->url()->without('field_id'), + null, + ['class' => 'icon-left-big'] + )); + } + $form->handleRequest(); + $this->content()->add($form); + $table = new IcingaObjectDatafieldTable($object); + $table->getAttributes()->set('data-base-target', '_self'); + $table->renderTo($this); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Security\SecurityException + */ + public function historyAction() + { + $this + ->assertTypePermission() + ->assertPermission('director/audit') + ->setAutorefreshInterval(10) + ->tabs()->activate('history'); + + $name = $this->requireObject()->getObjectName(); + $this->addTitle($this->translate('Activity Log: %s'), $name); + + $db = $this->db(); + $objectTable = $this->object->getTableName(); + $table = (new ActivityLogTable($db)) + ->setLastDeployedId($db->getLastDeploymentActivityLogId()) + ->filterObject($objectTable, $name); + if ($host = $this->params->get('host')) { + $table->filterHost($host); + } + $this->showOptionalBranchActivity($table); + $table->renderTo($this); + } + + /** + * @throws NotFoundError + */ + public function membershipAction() + { + $object = $this->requireObject(); + if (! $object instanceof IcingaObjectGroup) { + throw new NotFoundError('Not Found'); + } + + $this + ->addTitle($this->translate('Group membership: %s'), $object->getObjectName()) + ->setAutorefreshInterval(15) + ->tabs()->activate('membership'); + + $type = substr($this->getType(), 0, -5); + GroupMemberTable::create($type, $this->db()) + ->setGroup($object) + ->renderTo($this); + } + + /** + * @return $this + * @throws NotFoundError + */ + protected function addObjectTitle() + { + $object = $this->requireObject(); + $name = $object->getObjectName(); + if ($object->isTemplate()) { + $this->addTitle($this->translate('Template: %s'), $name); + } else { + $this->addTitle($name); + } + + return $this; + } + + /** + * @return $this + * @throws NotFoundError + */ + protected function addActionUsage() + { + $type = $this->getType(); + $object = $this->requireObject(); + if ($object->isTemplate() && $type !== 'serviceSet') { + $this->actions()->add([ + Link::create( + $this->translate('Usage'), + "director/${type}template/usage", + ['name' => $object->getObjectName()], + ['class' => 'icon-sitemap'] + ) + ]); + } + + return $this; + } + + protected function addActionClone() + { + $this->actions()->add(Link::create( + $this->translate('Clone'), + $this->getObjectBaseUrl() . '/clone', + $this->object->getUrlParams(), + array('class' => 'icon-paste') + )); + + return $this; + } + + /** + * @return $this + */ + protected function addActionBasket() + { + if ($this->hasBasketSupport()) { + $object = $this->object; + if ($object instanceof ExportInterface) { + if ($object instanceof IcingaCommand) { + if ($object->isExternal()) { + $type = 'ExternalCommand'; + } elseif ($object->isTemplate()) { + $type = 'CommandTemplate'; + } else { + $type = 'Command'; + } + } elseif ($object instanceof IcingaServiceSet) { + $type = 'ServiceSet'; + } elseif ($object->isTemplate()) { + $type = ucfirst($this->getType()) . 'Template'; + } elseif ($object->isGroup()) { + $type = ucfirst($this->getType()); + } else { + // Command? Sure? + $type = ucfirst($this->getType()); + } + $this->actions()->add(Link::create( + $this->translate('Add to Basket'), + 'director/basket/add', + [ + 'type' => $type, + 'names' => $object->getUniqueIdentifier() + ], + ['class' => 'icon-tag'] + )); + } + } + + return $this; + } + + protected function addTemplate() + { + $this->assertPermission('director/admin'); + $this->addTitle( + $this->translate('Add new Icinga %s template'), + $this->getTranslatedType() + ); + } + + protected function addObject() + { + $this->assertTypePermission(); + $imports = $this->params->get('imports'); + if (is_string($imports) && strlen($imports)) { + $this->addTitle( + $this->translate('Add %s: %s'), + $this->getTranslatedType(), + $imports + ); + } else { + $this->addTitle( + $this->translate('Add new Icinga %s'), + $this->getTranslatedType() + ); + } + } + + protected function redirectToPreviewForExternals() + { + if ($this->object + && $this->object->isExternal() + && ! in_array($this->object->getShortTableName(), $this->allowedExternals) + ) { + $this->redirectNow( + $this->getRequest()->getUrl()->setPath(sprintf('director/%s/render', $this->getType())) + ); + } + + return $this; + } + + protected function getType() + { + if ($this->type === null) { + // Strip final 's' and upcase an eventual 'group' + $this->type = preg_replace( + array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/set$/'), + array('Group', 'Period', 'Argument', 'ApiUser', 'Set'), + $this->getRequest()->getControllerName() + ); + } + + return $this->type; + } + + protected function getPluralType() + { + return $this->getType() . 's'; + } + + protected function getTranslatedType() + { + return $this->translate(ucfirst($this->getType())); + } + + protected function assertTypePermission() + { + $type = strtolower($this->getPluralType()); + // TODO: Check getPluralType usage, fix it there. + if ($type === 'scheduleddowntimes') { + $type = 'scheduled-downtimes'; + } + + return $this->assertPermission("director/$type"); + } + + protected function loadOptionalObject() + { + if ($this->params->get('uuid') || null !== $this->params->get('name') || $this->params->get('id')) { + $this->loadObject(); + } + } + + /** + * @return ?UuidInterface + * @throws InvalidPropertyException + * @throws NotFoundError + */ + protected function getUuidFromUrl() + { + $key = null; + if ($uuid = $this->params->get('uuid')) { + $key = Uuid::fromString($uuid); + } elseif ($id = $this->params->get('id')) { + $key = (int) $id; + } elseif (null !== ($name = $this->params->get('name'))) { + $key = $name; + } + if ($key === null) { + $request = $this->getRequest(); + if ($request->isApiRequest() && $request->isGet()) { + $this->getResponse()->setHttpResponseCode(422); + + throw new InvalidPropertyException( + 'Cannot load object, missing parameters' + ); + } + + return null; + } + + return $this->requireUuid($key); + } + + protected function loadObject() + { + if ($this->object) { + throw new ProgrammingError('Loading an object twice is not very efficient'); + } + + $this->object = $this->loadSpecificObject($this->getTableName(), $this->getUuidFromUrl(), true); + } + + protected function loadSpecificObject($tableName, $key, $showHint = false) + { + $branch = $this->getBranch(); + $branchedObject = BranchedObject::load($this->db(), $tableName, $key, $branch); + $object = $branchedObject->getBranchedDbObject($this->db()); + assert($object instanceof IcingaObject); + $object->setBeingLoadedFromDb(); + if (! $this->allowsObject($object)) { + throw new NotFoundError('No such object available'); + } + if ($showHint && $branch->isBranch() && $object->isObject() && ! $this->getRequest()->isApiRequest()) { + $this->content()->add(new BranchedObjectHint($branch, $this->Auth(), $branchedObject)); + } + + return $object; + } + + protected function requireUuid($key) + { + if (! $key instanceof UuidInterface) { + $key = UuidLookup::findUuidForKey($key, $this->getTableName(), $this->db(), $this->getBranch()); + if ($key === null) { + throw new NotFoundError('No such object available'); + } + } + + return $key; + } + + protected function getTableName() + { + return DbObjectTypeRegistry::tableNameByType($this->getType()); + } + + protected function addDeploymentLink() + { + try { + $info = new DeploymentInfo($this->db()); + $info->setObject($this->object); + + if (! $this->getRequest()->isApiRequest()) { + if ($this->getBranch()->isBranch()) { + $this->actions()->add($this->linkToMergeBranch($this->getBranch())); + } else { + $this->actions()->add( + DeploymentLinkForm::create( + $this->db(), + $info, + $this->Auth(), + $this->api() + )->handleRequest() + ); + } + } + } catch (IcingaException $e) { + // pass (deployment may not be set up yet) + } + } + + protected function linkToMergeBranch(Branch $branch) + { + $link = Branch::requireHook()->linkToBranch($branch, $this->Auth(), $this->translate('Merge')); + if ($link instanceof Link) { + $link->addAttributes(['class' => 'icon-flapping']); + } + + return $link; + } + + protected function addBackToObjectLink() + { + $params = [ + 'uuid' => $this->object->getUniqueId()->toString(), + ]; + + if ($this->object instanceof IcingaService) { + if (($host = $this->object->get('host')) !== null) { + $params['host'] = $host; + } elseif (($set = $this->object->get('service_set')) !== null) { + $params['set'] = $set; + } + } + + $this->actions()->add(Link::create( + $this->translate('back'), + $this->getObjectBaseUrl(), + $params, + ['class' => 'icon-left-big'] + )); + + return $this; + } + + protected function addObjectForm(IcingaObject $object = null) + { + $form = $this->loadObjectForm($object); + $this->content()->add($form); + $form->handleRequest(); + return $this; + } + + protected function loadObjectForm(IcingaObject $object = null) + { + /** @var DirectorObjectForm $class */ + $class = sprintf( + 'Icinga\\Module\\Director\\Forms\\Icinga%sForm', + ucfirst($this->getType()) + ); + + $form = $class::load() + ->setDb($this->db()) + ->setAuth($this->Auth()); + + if ($object !== null) { + $form->setObject($object); + } + if (true || $form->supportsBranches()) { + $form->setBranch($this->getBranch()); + } + + $this->onObjectFormLoaded($form); + + return $form; + } + + protected function getObjectBaseUrl() + { + return $this->objectBaseUrl ?: 'director/' . strtolower($this->getType()); + } + + protected function hasBasketSupport() + { + return $this->object->isTemplate() || $this->object->isGroup(); + } + + protected function onObjectFormLoaded(DirectorObjectForm $form) + { + } + + /** + * @return IcingaObject + * @throws NotFoundError + */ + protected function requireObject() + { + if (! $this->object) { + $this->getResponse()->setHttpResponseCode(404); + if (null === $this->params->get('name')) { + throw new NotFoundError('You need to pass a "name" parameter to access a specific object'); + } else { + throw new NotFoundError('No such object available'); + } + } + + return $this->object; + } + + protected function showOptionalBranchActivity($activityTable) + { + $branch = $this->getBranch(); + if ($branch->isBranch() && (int) $this->params->get('page', '1') === 1) { + $table = new BranchActivityTable($branch->getUuid(), $this->db(), $this->object->getUniqueId()); + if (count($table) > 0) { + $this->content()->add(Hint::info(Html::sprintf($this->translate( + 'The following modifications are visible in this %s only...' + ), Branch::requireHook()->linkToBranch( + $branch, + $this->Auth(), + $this->translate('configuration branch') + )))); + $this->content()->add($table); + if (count($activityTable) === 0) { + return; + } + $this->content()->add(Html::tag('br')); + $this->content()->add(Hint::ok($this->translate( + '...and the modifications below are already in the main branch:' + ))); + $this->content()->add(Html::tag('br')); + } + } + } +} diff --git a/library/Director/Web/Controller/ObjectsController.php b/library/Director/Web/Controller/ObjectsController.php new file mode 100644 index 0000000..8c10b44 --- /dev/null +++ b/library/Director/Web/Controller/ObjectsController.php @@ -0,0 +1,548 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\NotFoundError; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; +use Icinga\Module\Director\Forms\IcingaMultiEditForm; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\RestApi\IcingaObjectsHandler; +use Icinga\Module\Director\Web\ActionBar\ObjectsActionBar; +use Icinga\Module\Director\Web\ActionBar\TemplateActionBar; +use Icinga\Module\Director\Web\Form\FormLoader; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectSetTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Icinga\Module\Director\Web\Table\TemplatesTable; +use Icinga\Module\Director\Web\Tabs\ObjectsTabs; +use Icinga\Module\Director\Web\Tree\TemplateTreeRenderer; +use gipfl\IcingaWeb2\Link; +use Icinga\Module\Director\Web\Widget\AdditionalTableActions; +use Icinga\Module\Director\Web\Widget\BranchedObjectsHint; +use InvalidArgumentException; +use Ramsey\Uuid\Uuid; + +abstract class ObjectsController extends ActionController +{ + use BranchHelper; + + protected $isApified = true; + + /** @var ObjectsTable */ + protected $table; + + protected function checkDirectorPermissions() + { + if ($this->getRequest()->getActionName() !== 'sets') { + $this->assertPermission('director/' . $this->getPluralBaseType()); + } + } + + /** + * @return $this + * @throws \Icinga\Exception\Http\HttpNotFoundException + */ + protected function addObjectsTabs() + { + $tabName = $this->getRequest()->getActionName(); + if (substr($this->getType(), -5) === 'Group') { + $tabName = 'groups'; + } + $this->tabs(new ObjectsTabs( + $this->getBaseType(), + $this->Auth(), + $this->getBaseObjectUrl() + ))->activate($tabName); + + return $this; + } + + /** + * @return IcingaObjectsHandler + * @throws NotFoundError + */ + protected function apiRequestHandler() + { + $request = $this->getRequest(); + $table = $this->getTable(); + if ($request->getControllerName() === 'services' + && $host = $this->params->get('host') + ) { + $host = IcingaHost::load($host, $this->db()); + $table->getQuery()->where('o.host_id = ?', $host->get('id')); + } + + if ($request->getActionName() === 'templates') { + $table->filterObjectType('template'); + } elseif ($request->getActionName() === 'applyrules') { + $table->filterObjectType('apply'); + } + $search = $this->params->get('q'); + if ($search !== null && \strlen($search) > 0) { + $table->search($search); + } + + return (new IcingaObjectsHandler( + $request, + $this->getResponse(), + $this->db() + ))->setTable($table); + } + + /** + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws NotFoundError + */ + public function indexAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + + $type = $this->getType(); + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $this + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle($this->translate(ucfirst($this->getPluralType()))) + ->actions(new ObjectsActionBar($this->getBaseObjectUrl(), $this->url())); + + $this->content()->add(new BranchedObjectsHint($this->getBranch(), $this->Auth())); + + if ($type === 'command' && $this->params->get('type') === 'external_object') { + $this->tabs()->activate('external'); + } + + // Hint: might be used in controllers extending this + $this->table = $this->eventuallyFilterCommand($this->getTable()); + + $this->table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $this->table)) + ->appendTo($this->actions()); + } + + /** + * @return ObjectsTable + */ + protected function getTable() + { + $table = ObjectsTable::create($this->getType(), $this->db()) + ->setAuth($this->getAuth()) + ->setBranchUuid($this->getBranchUuid()) + ->setBaseObjectUrl($this->getBaseObjectUrl()); + + return $table; + } + + /** + * @return ApplyRulesTable + * @throws NotFoundError + */ + protected function getApplyRulesTable() + { + $table = new ApplyRulesTable($this->db()); + $table->setType($this->getType()) + ->setBaseObjectUrl($this->getBaseObjectUrl()); + $this->eventuallyFilterCommand($table); + + return $table; + } + + /** + * @throws NotFoundError + */ + public function edittemplatesAction() + { + $this->commonForEdit(); + } + + /** + * @throws NotFoundError + */ + public function editAction() + { + $this->commonForEdit(); + } + + /** + * @throws NotFoundError + */ + public function commonForEdit() + { + $type = ucfirst($this->getType()); + + if (empty($this->multiEdit)) { + throw new NotFoundError('Cannot edit multiple "%s" instances', $type); + } + + $objects = $this->loadMultiObjectsFromParams(); + if (empty($objects)) { + throw new NotFoundError('No "%s" instances have been loaded', $type); + } + $formName = 'icinga' . $type; + $form = IcingaMultiEditForm::load() + ->setBranch($this->getBranch()) + ->setObjects($objects) + ->pickElementsFrom($this->loadForm($formName), $this->multiEdit); + if ($type === 'Service') { + $form->setListUrl('director/services'); + } elseif ($type === 'Host') { + $form->setListUrl('director/hosts'); + } + + $form->handleRequest(); + + $this + ->addSingleTab($this->translate('Multiple objects')) + ->addTitle( + $this->translate('Modify %d objects'), + count($objects) + )->content()->add($form); + } + + /** + * Loads the TemplatesTable or the TemplateTreeRenderer + * + * Passing render=tree switches to the tree view. + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + * @throws NotFoundError + */ + public function templatesAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + $type = $this->getType(); + + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}-templates_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $shortType = IcingaObject::createByType($type)->getShortTableName(); + $this + ->assertPermission('director/admin') + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('All your %s Templates'), + $this->translate(ucfirst($type)) + ) + ->actions(new TemplateActionBar($shortType, $this->url())); + + if ($this->params->get('render') === 'tree') { + TemplateTreeRenderer::showType($shortType, $this, $this->db()); + } else { + $table = TemplatesTable::create($shortType, $this->db()); + $this->eventuallyFilterCommand($table); + $table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $table)) + ->appendTo($this->actions()); + } + } + + /** + * @return $this + * @throws \Icinga\Security\SecurityException + */ + protected function assertApplyRulePermission() + { + return $this->assertPermission('director/admin'); + } + + /** + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + * @throws NotFoundError + */ + public function applyrulesAction() + { + if ($this->getRequest()->isApiRequest()) { + $this->apiRequestHandler()->dispatch(); + return; + } + + $type = $this->getType(); + + if ($this->params->get('format') === 'json') { + $filename = sprintf( + "director-${type}-applyrules_%s.json", + date('YmdHis') + ); + $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true); + $this->apiRequestHandler()->dispatch(); + return; + } + + $tType = $this->translate(ucfirst($type)); + $this + ->assertApplyRulePermission() + ->addObjectsTabs() + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('All your %s Apply Rules'), + $tType + ); + $baseUrl = 'director/' . $this->getBaseObjectUrl(); + $this->actions() + //->add($this->getBackToDashboardLink()) + ->add( + Link::create( + $this->translate('Add'), + "${baseUrl}/add", + ['type' => 'apply'], + [ + 'title' => sprintf( + $this->translate('Create a new %s Apply Rule'), + $tType + ), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + + $table = $this->getApplyRulesTable(); + $table->renderTo($this); + (new AdditionalTableActions($this->getAuth(), $this->url(), $table)) + ->appendTo($this->actions()); + } + + /** + * @throws NotFoundError + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Security\SecurityException + */ + public function setsAction() + { + $type = $this->getType(); + $tType = $this->translate(ucfirst($type)); + $this + ->assertPermission('director/' . $this->getBaseType() . 'sets') + ->addObjectsTabs() + ->requireSupportFor('Sets') + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('Icinga %s Sets'), + $tType + ); + + $this->actions()->add( + Link::create( + $this->translate('Add'), + "director/${type}set/add", + null, + [ + 'title' => sprintf( + $this->translate('Create a new %s Set'), + $tType + ), + 'class' => 'icon-plus', + 'data-base-target' => '_next' + ] + ) + ); + + ObjectSetTable::create($type, $this->db(), $this->getAuth()) + ->setBranch($this->getBranch()) + ->renderTo($this); + } + + /** + * @return array + * @throws NotFoundError + */ + protected function loadMultiObjectsFromParams() + { + $filter = Filter::fromQueryString($this->params->toString()); + $type = $this->getType(); + $objects = array(); + $db = $this->db(); + $class = DbObjectTypeRegistry::classByType($type); + $table = DbObjectTypeRegistry::tableNameByType($type); + $store = new DbObjectStore($db, $this->getBranch()); + + /** @var $filter FilterChain */ + foreach ($filter->filters() as $sub) { + /** @var $sub FilterChain */ + foreach ($sub->filters() as $ex) { + /** @var $ex FilterChain|FilterExpression */ + $col = $ex->getColumn(); + if ($ex->isExpression()) { + if ($col === 'name') { + $name = $ex->getExpression(); + if ($type === 'service') { + $key = [ + 'object_type' => 'template', + 'object_name' => $name + ]; + } else { + $key = $name; + } + $objects[$name] = $class::load($key, $db); + } elseif ($col === 'id') { + $name = $ex->getExpression(); + $objects[$name] = $class::load($name, $db); + } elseif ($col === 'uuid') { + $object = $store->load($table, Uuid::fromString($ex->getExpression())); + $objects[$object->getObjectName()] = $object; + } else { + throw new InvalidArgumentException("'$col' is no a valid key component for '$type'"); + } + } + } + } + + return $objects; + } + + /** + * @param string $name + * + * @return \Icinga\Module\Director\Web\Form\QuickForm + */ + public function loadForm($name) + { + $form = FormLoader::load($name, $this->Module()); + if ($this->getRequest()->isApiRequest()) { + // TODO: Ask form for API support? + $form->setApiRequest(); + } + + return $form; + } + + /** + * @param ZfQueryBasedTable $table + * @return ZfQueryBasedTable + * @throws NotFoundError + */ + protected function eventuallyFilterCommand(ZfQueryBasedTable $table) + { + if ($this->params->get('command')) { + $command = IcingaCommand::load($this->params->get('command'), $this->db()); + switch ($this->getBaseType()) { + case 'host': + case 'service': + $table->getQuery()->where( + $this->db()->getDbAdapter()->quoteInto( + '(o.check_command_id = ? OR o.event_command_id = ?)', + $command->getAutoincId() + ) + ); + break; + case 'notification': + $table->getQuery()->where( + 'o.command_id = ?', + $command->getAutoincId() + ); + break; + } + } + + return $table; + } + + /** + * @param $feature + * @return $this + * @throws NotFoundError + */ + protected function requireSupportFor($feature) + { + if ($this->supports($feature) !== true) { + throw new NotFoundError( + '%s does not support %s', + $this->getType(), + $feature + ); + } + + return $this; + } + + /** + * @param $feature + * @return bool + */ + protected function supports($feature) + { + $func = "supports$feature"; + return IcingaObject::createByType($this->getType())->$func(); + } + + /** + * @return string + */ + protected function getBaseType() + { + $type = $this->getType(); + if (substr($type, -5) === 'Group') { + return substr($type, 0, -5); + } else { + return $type; + } + } + + protected function getBaseObjectUrl() + { + return $this->getType(); + } + + /** + * @return string + */ + protected function getType() + { + // Strip final 's' and upcase an eventual 'group' + return preg_replace( + array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/dependencie$/', '/set$/'), + array('Group', 'Period', 'Argument', 'ApiUser', 'dependency', 'Set'), + str_replace( + 'template', + '', + substr($this->getRequest()->getControllerName(), 0, -1) + ) + ); + } + + /** + * @return string + */ + protected function getPluralType() + { + return preg_replace('/cys$/', 'cies', $this->getType() . 's'); + } + + /** + * @return string + */ + protected function getPluralBaseType() + { + return preg_replace('/cys$/', 'cies', $this->getBaseType() . 's'); + } +} diff --git a/library/Director/Web/Controller/TemplateController.php b/library/Director/Web/Controller/TemplateController.php new file mode 100644 index 0000000..c368a82 --- /dev/null +++ b/library/Director/Web/Controller/TemplateController.php @@ -0,0 +1,243 @@ +<?php + +namespace Icinga\Module\Director\Web\Controller; + +use gipfl\Web\Widget\Hint; +use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Module\Director\Web\Table\ApplyRulesTable; +use Icinga\Module\Director\Web\Table\ObjectsTable; +use Icinga\Module\Director\Web\Table\TemplatesTable; +use Icinga\Module\Director\Web\Table\TemplateUsageTable; +use Icinga\Module\Director\Web\Tabs\ObjectTabs; +use Icinga\Module\Director\Web\Widget\UnorderedList; +use ipl\Html\FormattedString; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\CompatController; + +abstract class TemplateController extends CompatController +{ + use DirectorDb; + + /** @var IcingaObject */ + protected $template; + + public function objectsAction() + { + $template = $this->requireTemplate(); + $plural = $this->getTranslatedPluralType(); + $this + ->addSingleTab($plural) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('%s based on %s'), + $plural, + $template->getObjectName() + )->addBackToUsageLink($template); + + ObjectsTable::create($this->getType(), $this->db()) + ->setAuth($this->Auth()) + ->setBaseObjectUrl($this->getBaseObjectUrl()) + ->filterTemplate($template, $this->getInheritance()) + ->renderTo($this); + } + + public function applyrulesAction() + { + $type = $this->getType(); + $template = $this->requireTemplate(); + $this + ->addSingleTab(sprintf($this->translate('Applied %s'), $this->getTranslatedPluralType())) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('Notification Apply Rules based on %s'), + $template->getObjectName() + )->addBackToUsageLink($template); + + ApplyRulesTable::create($type, $this->db()) + ->setBaseObjectUrl($this->getBaseObjectUrl()) + ->filterTemplate($template, $this->params->get('inheritance', 'direct')) + ->renderTo($this); + } + + public function templatesAction() + { + $template = $this->requireTemplate(); + $typeName = $this->getTranslatedType(); + $this + ->addSingleTab(sprintf($this->translate('%s Templates'), $typeName)) + ->setAutorefreshInterval(10) + ->addTitle( + $this->translate('%s templates based on %s'), + $typeName, + $template->getObjectName() + )->addBackToUsageLink($template); + + TemplatesTable::create($this->getType(), $this->db()) + ->filterTemplate($template, $this->getInheritance()) + ->renderTo($this); + } + + protected function getInheritance() + { + return $this->params->get('inheritance', 'direct'); + } + + protected function addBackToUsageLink(IcingaObject $template) + { + $type = $this->getType(); + $this->actions()->add( + Link::create( + $this->translate('Back'), + "director/${type}template/usage", + ['name' => $template->getObjectName()], + ['class' => 'icon-left-big'] + ) + ); + + return $this; + } + + public function usageAction() + { + $template = $this->requireTemplate(); + if (! $template->isTemplate() && $template instanceof IcingaCommand) { + $this->redirectNow($this->url()->setPath('director/command')); + } + $templateName = $template->getObjectName(); + + $type = $this->getType(); + $this->tabs(new ObjectTabs($type, $this->Auth(), $template))->activate('modify'); + $this + ->addTitle($this->translate('Template: %s'), $templateName) + ->setAutorefreshInterval(10); + + $this->actions()->add([ + Link::create( + $this->translate('Modify'), + "director/$type/edit", + ['uuid' => $template->getUniqueId()->toString()], + ['class' => 'icon-edit'] + ) + ]); + if ($template instanceof ExportInterface) { + $this->actions()->add(Link::create( + $this->translate('Add to Basket'), + 'director/basket/add', + [ + 'type' => ucfirst($this->getType()) . 'Template', + 'names' => $template->getUniqueIdentifier() + ], + ['class' => 'icon-tag'] + )); + } + + $list = new UnorderedList([], [ + 'class' => 'vertical-action-list' + ]); + + $auth = $this->Auth(); + + if ($type !== 'notification') { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this template'), + [Link::create( + $this->translate('Object'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'object'] + )] + )); + } + if ($auth->hasPermission('director/admin')) { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this one'), + [Link::create( + $this->translate('Template'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'template'] + )] + )); + } + if ($template->supportsApplyRules()) { + $list->addItem(new FormattedString( + $this->translate('Create a new %s inheriting from this template'), + [Link::create( + $this->translate('Apply Rule'), + "director/$type/add", + ['imports' => $templateName, 'type' => 'apply'] + )] + )); + } + + $typeName = $this->getTranslatedType(); + $this->content()->add(Html::sprintf( + $this->translate( + 'This is the "%s" %s Template. Based on this, you might want to:' + ), + $typeName, + $templateName + ))->add( + $list + )->add( + Html::tag('h2', null, $this->translate('Current Template Usage')) + ); + + try { + $this->content()->add( + TemplateUsageTable::forTemplate($template) + ); + } catch (NestingError $e) { + $this->content()->add(Hint::error($e->getMessage())); + } + } + + protected function getType() + { + return $this->template()->getShortTableName(); + } + + protected function getPluralType() + { + return preg_replace( + '/cys$/', + 'cies', + $this->template()->getShortTableName() . 's' + ); + } + + protected function getTranslatedType() + { + return $this->translate(ucfirst($this->getType())); + } + + protected function getTranslatedPluralType() + { + return $this->translate(ucfirst($this->getPluralType())); + } + + protected function getBaseObjectUrl() + { + return $this->getType(); + } + + /** + * @return IcingaObject + */ + protected function template() + { + if ($this->template === null) { + $this->template = $this->requireTemplate(); + } + + return $this->template; + } + + /** + * @return IcingaObject + */ + abstract protected function requireTemplate(); +} diff --git a/library/Director/Web/Form/ClickHereForm.php b/library/Director/Web/Form/ClickHereForm.php new file mode 100644 index 0000000..abba9d7 --- /dev/null +++ b/library/Director/Web/Form/ClickHereForm.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\InlineForm; + +class ClickHereForm extends InlineForm +{ + use TranslationHelper; + + protected $hasBeenClicked = false; + + protected function assemble() + { + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('here'), + 'class' => 'link-button' + ]); + } + + public function hasBeenClicked() + { + return $this->hasBeenClicked; + } + + public function onSuccess() + { + $this->hasBeenClicked = true; + } +} diff --git a/library/Director/Web/Form/CloneImportSourceForm.php b/library/Director/Web/Form/CloneImportSourceForm.php new file mode 100644 index 0000000..0849dd4 --- /dev/null +++ b/library/Director/Web/Form/CloneImportSourceForm.php @@ -0,0 +1,72 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Module\Director\Data\Exporter; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DdDtDecorator; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\ImportSource; + +class CloneImportSourceForm extends Form +{ + use TranslationHelper; + + /** @var ImportSource */ + protected $source; + + /** @var ImportSource|null */ + protected $newSource; + + public function __construct(ImportSource $source) + { + $this->setDefaultElementDecorator(new DdDtDecorator()); + $this->source = $source; + } + + protected function assemble() + { + $this->addElement('text', 'source_name', [ + 'label' => $this->translate('New name'), + 'value' => $this->source->get('source_name'), + ]); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Clone') + ]); + } + + /** + * @return \Icinga\Module\Director\Db + */ + protected function getTargetDb() + { + return $this->source->getConnection(); + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $db = $this->getTargetDb(); + $export = (new Exporter($db))->export($this->source); + $newName = $this->getElement('source_name')->getValue(); + $export->source_name = $newName; + unset($export->originalId); + if (ImportSource::existsWithName($newName, $db)) { + $this->getElement('source_name')->addMessage('Name already exists'); + } + $this->newSource = ImportSource::import($export, $db); + $this->newSource->store(); + } + + public function getSuccessUrl() + { + if ($this->newSource === null) { + return parent::getSuccessUrl(); + } else { + return Url::fromPath('director/importsource', ['id' => $this->newSource->get('id')]); + } + } +} diff --git a/library/Director/Web/Form/CloneSyncRuleForm.php b/library/Director/Web/Form/CloneSyncRuleForm.php new file mode 100644 index 0000000..f90b593 --- /dev/null +++ b/library/Director/Web/Form/CloneSyncRuleForm.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Module\Director\Data\Exporter; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DdDtDecorator; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\SyncRule; + +class CloneSyncRuleForm extends Form +{ + use TranslationHelper; + + /** @var SyncRule */ + protected $rule; + + /** @var SyncRule|null */ + protected $newRule; + + public function __construct(SyncRule $rule) + { + $this->setDefaultElementDecorator(new DdDtDecorator()); + $this->rule = $rule; + } + + protected function assemble() + { + $this->addElement('text', 'rule_name', [ + 'label' => $this->translate('New name'), + 'value' => $this->rule->get('rule_name'), + ]); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Clone') + ]); + } + + /** + * @return \Icinga\Module\Director\Db + */ + protected function getTargetDb() + { + return $this->rule->getConnection(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $db = $this->getTargetDb(); + $exporter = new Exporter($db); + + $export = $exporter->export($this->rule); + $newName = $this->getValue('rule_name'); + $export->rule_name = $newName; + unset($export->originalId); + + if (SyncRule::existsWithName($newName, $db)) { + $this->getElement('rule_name')->addMessage('Name already exists'); + } + $this->newRule = SyncRule::import($export, $db); + $this->newRule->store(); + } + + public function getSuccessUrl() + { + if ($this->newRule === null) { + return parent::getSuccessUrl(); + } else { + return Url::fromPath('director/syncrule', ['id' => $this->newRule->get('id')]); + } + } +} diff --git a/library/Director/Web/Form/CsrfToken.php b/library/Director/Web/Form/CsrfToken.php new file mode 100644 index 0000000..24edf88 --- /dev/null +++ b/library/Director/Web/Form/CsrfToken.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +class CsrfToken +{ + /** + * Check whether the given token is valid + * + * @param string $token Token + * + * @return bool + */ + public static function isValid($token) + { + if (strpos($token, '|') === false) { + return false; + } + + list($seed, $token) = explode('|', $elementValue); + + if (!is_numeric($seed)) { + return false; + } + + return $token === hash('sha256', self::getSessionId() . $seed); + } + + /** + * Create a new token + * + * @return string + */ + public static function generate() + { + $seed = mt_rand(); + $token = hash('sha256', self::getSessionId() . $seed); + + return sprintf('%s|%s', $seed, $token); + } + + /** + * Get current session id + * + * TODO: we should do this through our App or Session object + * + * @return string + */ + protected static function getSessionId() + { + return session_id(); + } +} diff --git a/library/Director/Web/Form/DbSelectorForm.php b/library/Director/Web/Form/DbSelectorForm.php new file mode 100644 index 0000000..52fe5ea --- /dev/null +++ b/library/Director/Web/Form/DbSelectorForm.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use gipfl\IcingaWeb2\Url; +use Icinga\Web\Response; +use ipl\Html\Form; +use Icinga\Web\Window; + +class DbSelectorForm extends Form +{ + protected $defaultAttributes = [ + 'class' => 'db-selector' + ]; + + protected $allowedNames; + + /** @var Window */ + protected $window; + + protected $response; + + public function __construct(Response $response, Window $window, $allowedNames) + { + $this->response = $response; + $this->window = $window; + $this->allowedNames = $allowedNames; + } + + protected function assemble() + { + $this->addElement('hidden', 'DbSelector', [ + 'value' => 'sent' + ]); + $this->addElement('select', 'db_resource', [ + 'options' => $this->allowedNames, + 'class' => 'autosubmit', + 'value' => $this->getSession()->get('db_resource') + ]); + } + + /** + * A base class should handle this, based on hidden fields + * + * @return bool + */ + public function hasBeenSubmitted() + { + return $this->hasBeenSent() && $this->getRequestParam('DbSelector') === 'sent'; + } + + public function onSuccess() + { + $this->getSession()->set('db_resource', $this->getElement('db_resource')->getValue()); + $this->response->redirectAndExit(Url::fromRequest($this->getRequest())); + } + + protected function getRequestParam($name, $default = null) + { + $request = $this->getRequest(); + if ($request === null) { + return $default; + } + if ($request->getMethod() === 'POST') { + $params = $request->getParsedBody(); + } elseif ($this->getMethod() === 'GET') { + parse_str($request->getUri()->getQuery(), $params); + } else { + $params = []; + } + + if (array_key_exists($name, $params)) { + return $params[$name]; + } + + return $default; + } + /** + * @return \Icinga\Web\Session\SessionNamespace + */ + protected function getSession() + { + return $this->window->getSessionNamespace('director'); + } +} diff --git a/library/Director/Web/Form/Decorator/ViewHelperRaw.php b/library/Director/Web/Form/Decorator/ViewHelperRaw.php new file mode 100644 index 0000000..a3aefbf --- /dev/null +++ b/library/Director/Web/Form/Decorator/ViewHelperRaw.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Decorator; + +use Zend_Form_Decorator_ViewHelper as ViewHelper; +use Zend_Form_Element as Element; + +class ViewHelperRaw extends ViewHelper +{ + public function getValue($element) + { + return $element->getUnfilteredValue(); + } +} diff --git a/library/Director/Web/Form/DirectorForm.php b/library/Director/Web/Form/DirectorForm.php new file mode 100644 index 0000000..145be5b --- /dev/null +++ b/library/Director/Web/Form/DirectorForm.php @@ -0,0 +1,58 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Module\Director\Db; + +abstract class DirectorForm extends QuickForm +{ + /** @var Db */ + protected $db; + + /** + * @param Db $db + * @return $this + */ + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + /** + * @return Db + */ + public function getDb() + { + return $this->db; + } + + /** + * @return static + */ + public static function load() + { + return new static([ + 'icingaModule' => Icinga::App()->getModuleManager()->getModule('director') + ]); + } + + public function addBoolean($key, $options, $default = null) + { + if ($default === null) { + return $this->addElement('OptionalYesNo', $key, $options); + } else { + $this->addElement('YesNo', $key, $options); + return $this->getElement($key)->setValue($default); + } + } + + protected function optionalBoolean($key, $label, $description) + { + return $this->addBoolean($key, array( + 'label' => $label, + 'description' => $description + )); + } +} diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php new file mode 100644 index 0000000..b70bd7b --- /dev/null +++ b/library/Director/Web/Form/DirectorObjectForm.php @@ -0,0 +1,1734 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Exception; +use gipfl\IcingaWeb2\Url; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Hook\IcingaObjectFormHook; +use Icinga\Module\Director\IcingaConfig\StateFilterSet; +use Icinga\Module\Director\IcingaConfig\TypeFilterSet; +use Icinga\Module\Director\Objects\IcingaTemplateChoice; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Form\Element\ExtensibleSet; +use Icinga\Module\Director\Web\Form\Validate\NamePattern; +use Zend_Form_Element as ZfElement; +use Zend_Form_Element_Select as ZfSelect; + +abstract class DirectorObjectForm extends DirectorForm +{ + const GROUP_ORDER_OBJECT_DEFINITION = 20; + const GROUP_ORDER_RELATED_OBJECTS = 25; + const GROUP_ORDER_ASSIGN = 30; + const GROUP_ORDER_CHECK_EXECUTION = 40; + const GROUP_ORDER_CUSTOM_FIELDS = 50; + const GROUP_ORDER_CUSTOM_FIELD_CATEGORIES = 60; + const GROUP_ORDER_EVENT_FILTERS = 700; + const GROUP_ORDER_EXTRA_INFO = 750; + const GROUP_ORDER_CLUSTERING = 800; + const GROUP_ORDER_BUTTONS = 1000; + + /** @var IcingaObject */ + protected $object; + + /** @var Branch */ + protected $branch; + + protected $objectName; + + protected $className; + + protected $deleteButtonName; + + protected $displayGroups = []; + + protected $resolvedImports; + + protected $listUrl; + + /** @var Auth */ + private $auth; + + private $choiceElements = []; + + protected $preferredObjectType; + + /** @var IcingaObjectFieldLoader */ + protected $fieldLoader; + + private $allowsExperimental; + + private $presetImports; + + private $earlyProperties = array( + // 'imports', + 'check_command', + 'check_command_id', + 'has_agent', + 'command', + 'command_id', + 'event_command', + 'event_command_id', + ); + + public function setPreferredObjectType($type) + { + $this->preferredObjectType = $type; + return $this; + } + + public function setAuth(Auth $auth) + { + $this->auth = $auth; + return $this; + } + + public function getAuth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + protected function eventuallyAddNameRestriction($restrictionName) + { + $restrictions = $this->getAuth()->getRestrictions($restrictionName); + if (! empty($restrictions)) { + $this->getElement('object_name')->addValidator( + new NamePattern($restrictions) + ); + } + + return $this; + } + + public function presetImports($imports) + { + if (! empty($imports)) { + if (is_array($imports)) { + $this->presetImports = $imports; + } else { + $this->presetImports = array($imports); + } + } + + return $this; + } + + /** + * @return DbObject|DbObjectWithSettings|IcingaObject + */ + protected function object() + { + if ($this->object === null) { + $values = $this->getValues(); + /** @var DbObject|IcingaObject $class */ + $class = $this->getObjectClassname(); + if ($this->preferredObjectType) { + $values['object_type'] = $this->preferredObjectType; + } + if ($this->presetImports) { + $values['imports'] = $this->presetImports; + } + + $this->object = $class::create($values, $this->db); + } else { + if (! $this->object->hasConnection()) { + $this->object->setConnection($this->db); + } + } + + return $this->object; + } + + protected function extractChoicesFromPost($post) + { + $imports = []; + + foreach ($this->choiceElements as $other) { + $name = $other->getName(); + if (array_key_exists($name, $post)) { + $value = $post[$name]; + if (is_string($value)) { + $imports[] = $value; + } elseif (is_array($value)) { + foreach ($value as $chosen) { + $imports[] = $chosen; + } + } + } + } + + return $imports; + } + + protected function assertResolvedImports() + { + if ($this->resolvedImports !== null) { + return $this->resolvedImports; + } + + $object = $this->object; + + if (! $object instanceof IcingaObject) { + return $this->setResolvedImports(false); + } + if (! $object->supportsImports()) { + return $this->setResolvedImports(false); + } + + if ($this->hasBeenSent()) { + // prefill special properties, required to resolve fields and similar + $post = $this->getRequest()->getPost(); + + $key = 'imports'; + if ($el = $this->getElement($key)) { + if (array_key_exists($key, $post)) { + $imports = $post[$key]; + if (! is_array($imports)) { + $imports = array($imports); + } + $imports = array_filter(array_values(array_merge( + $imports, + $this->extractChoicesFromPost($post) + )), 'strlen'); + + /** @var ZfElement $el */ + $this->populate([$key => $imports]); + $el->setValue($imports); + if (! $this->tryToSetObjectPropertyFromElement($object, $el, $key)) { + return $this->resolvedImports = false; + } + } + } elseif ($this->presetImports) { + $imports = array_values(array_merge( + $this->presetImports, + $this->extractChoicesFromPost($post) + )); + if (! $this->eventuallySetImports($imports)) { + return $this->resolvedImports = false; + } + } else { + if (! empty($this->choiceElements)) { + if (! $this->eventuallySetImports($this->extractChoicesFromPost($post))) { + return $this->resolvedImports = false; + } + } + } + + foreach ($this->earlyProperties as $key) { + if ($el = $this->getElement($key)) { + if (array_key_exists($key, $post)) { + $this->populate([$key => $post[$key]]); + $this->tryToSetObjectPropertyFromElement($object, $el, $key); + } + } + } + } + + try { + $object->listAncestorIds(); + } catch (NestingError $e) { + $this->addUniqueErrorMessage($e->getMessage()); + return $this->resolvedImports = false; + } catch (Exception $e) { + $this->addException($e, 'imports'); + return $this->resolvedImports = false; + } + + return $this->setResolvedImports(); + } + + protected function eventuallySetImports($imports) + { + try { + $this->object()->set('imports', $imports); + return true; + } catch (Exception $e) { + $this->addException($e, 'imports'); + return false; + } + } + + protected function tryToSetObjectPropertyFromElement( + IcingaObject $object, + ZfElement $element, + $key + ) { + $old = null; + try { + $old = $object->get($key); + $object->set($key, $element->getValue()); + $object->resolveUnresolvedRelatedProperties(); + + if ($key === 'imports') { + $object->imports()->getObjects(); + } + return true; + } catch (Exception $e) { + if ($old !== null) { + $object->set($key, $old); + } + $this->addException($e, $key); + return false; + } + } + + public function setResolvedImports($resolved = true) + { + return $this->resolvedImports = $resolved; + } + + public function isObject() + { + return $this->getSentOrObjectValue('object_type') === 'object'; + } + + public function isTemplate() + { + return $this->getSentOrObjectValue('object_type') === 'template'; + } + + // TODO: move to a subform + protected function handleRanges(IcingaObject $object, &$values) + { + if (! $object->supportsRanges()) { + return; + } + + $key = 'ranges'; + $object = $this->object(); + + /* Sample: + + array( + 'monday' => 'eins', + 'tuesday' => '00:00-24:00', + 'sunday' => 'zwei', + ); + + */ + if (array_key_exists($key, $values)) { + $object->ranges()->set($values[$key]); + unset($values[$key]); + } + + foreach ($object->ranges()->getRanges() as $key => $value) { + $this->addRange($key, $value); + } + } + + protected function addToCheckExecutionDisplayGroup($elements) + { + return $this->addElementsToGroup( + $elements, + 'check_execution', + self::GROUP_ORDER_CHECK_EXECUTION, + $this->translate('Check execution') + ); + } + + public function addElementsToGroup($elements, $group, $order, $legend = null) + { + if (! is_array($elements)) { + $elements = array($elements); + } + + // These are optional elements, they might exist or not. We still want + // to see exception for other ones + $skipLegally = array('check_period_id'); + + $skip = array(); + foreach ($elements as $k => $v) { + if (is_string($v)) { + $el = $this->getElement($v); + if (!$el && in_array($v, $skipLegally)) { + $skip[] = $k; + continue; + } + + $elements[$k] = $el; + } + } + + foreach ($skip as $k) { + unset($elements[$k]); + } + + if (! array_key_exists($group, $this->displayGroups)) { + $this->addDisplayGroup($elements, $group, array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => $order, + 'legend' => $legend ?: $group, + )); + $this->displayGroups[$group] = $this->getDisplayGroup($group); + } else { + $this->displayGroups[$group]->addElements($elements); + } + + return $this->displayGroups[$group]; + } + + protected function handleProperties(DbObject $object, &$values) + { + if ($this->hasBeenSent()) { + foreach ($values as $key => $value) { + try { + if ($key === 'imports' && ! empty($this->choiceElements)) { + if (! is_array($value)) { + $value = [$value]; + } + foreach ($this->choiceElements as $element) { + $chosen = $element->getValue(); + if (is_string($chosen)) { + $value[] = $chosen; + } elseif (is_array($chosen)) { + foreach ($chosen as $choice) { + $value[] = $choice; + } + } + } + } + $object->set($key, $value); + if ($object instanceof IcingaObject) { + if ($this->resolvedImports !== false) { + $object->imports()->getObjects(); + } + } + } catch (Exception $e) { + $this->addException($e, $key); + } + } + } + } + + protected function loadInheritedProperties() + { + if ($this->assertResolvedImports()) { + try { + $this->showInheritedProperties($this->object()); + } catch (Exception $e) { + $this->addException($e); + } + } + } + + protected function showInheritedProperties(IcingaObject $object) + { + $inherited = $object->getInheritedProperties(); + $origins = $object->getOriginsProperties(); + + foreach ($inherited as $k => $v) { + if ($v !== null && $k !== 'object_name') { + $el = $this->getElement($k); + if ($el) { + $this->setInheritedValue($el, $inherited->$k, $origins->$k); + } elseif (substr($k, -3) === '_id') { + $k = substr($k, 0, -3); + $el = $this->getElement($k); + if ($el) { + $this->setInheritedValue( + $el, + $object->getRelatedObjectName($k, $v), + $origins->{"${k}_id"} + ); + } + } + } + } + } + + protected function prepareFields($object) + { + if ($this->assertResolvedImports()) { + $this->fieldLoader = new IcingaObjectFieldLoader($object); + $this->fieldLoader->prepareElements($this); + } + + return $this; + } + + protected function setCustomVarValues($values) + { + if ($this->fieldLoader) { + $this->fieldLoader->setValues($values, 'var_'); + } + + return $this; + } + + protected function addFields() + { + if ($this->fieldLoader) { + $this->fieldLoader->addFieldsToForm($this); + $this->onAddedFields(); + } + } + + protected function onAddedFields() + { + } + + // TODO: remove, used in sets I guess + protected function fieldLoader($object) + { + if ($this->fieldLoader === null) { + $this->fieldLoader = new IcingaObjectFieldLoader($object); + } + + return $this->fieldLoader; + } + + protected function isNew() + { + return $this->object === null || ! $this->object->hasBeenLoadedFromDb(); + } + + protected function setButtons() + { + if ($this->isNew()) { + $this->setSubmitLabel( + $this->translate('Add') + ); + } else { + $this->setSubmitLabel( + $this->translate('Store') + ); + $this->addDeleteButton(); + } + } + + /** + * @param bool $importsFirst + * @return $this + */ + protected function groupMainProperties($importsFirst = false) + { + if ($importsFirst) { + $elements = [ + 'imports', + 'object_type', + 'object_name', + ]; + } else { + $elements = [ + 'object_type', + 'object_name', + 'imports', + ]; + } + $elements = array_merge($elements, [ + 'display_name', + 'host', + 'host_id', + 'address', + 'address6', + 'groups', + 'inherited_groups', + 'applied_groups', + 'users', + 'user_groups', + 'apply_to', + 'command_id', // Notification + 'notification_interval', + 'period_id', + 'times_begin', + 'times_end', + 'email', + 'pager', + 'enable_notifications', + 'disable_checks', //Dependencies + 'disable_notifications', + 'ignore_soft_states', + 'apply_for', + 'create_live', + 'disabled', + ]); + + // Add template choices to the main section + /** @var \Zend_Form_Element $el */ + foreach ($this->getElements() as $key => $el) { + if (substr($el->getName(), 0, 6) === 'choice') { + $elements[] = $key; + } + } + + $this->addDisplayGroup($elements, 'object_definition', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_OBJECT_DEFINITION, + 'legend' => $this->translate('Main properties') + )); + + return $this; + } + + protected function setSentValue($name, $value) + { + if ($this->hasBeenSent()) { + $request = $this->getRequest(); + if ($value !== null && $request->isPost() && $request->getPost($name) !== null) { + $request->setPost($name, $value); + } + } + + $this->setElementValue($name, $value); + } + + public function setElementValue($name, $value = null) + { + $el = $this->getElement($name); + if (! $el) { + // Not showing an error, as most object properties do not exist. Not + // yet, because IMO this should be checked. + // $this->addError(sprintf($this->translate('Form element "%s" does not exist'), $name)); + return; + } + + if ($value !== null) { + $el->setValue($value); + } + } + + public function setInheritedValue(ZfElement $el, $inherited, $inheritedFrom) + { + if ($inherited === null) { + return; + } + + $txtInherited = sprintf($this->translate(' (inherited from "%s")'), $inheritedFrom); + if ($el instanceof ZfSelect) { + $multi = $el->getMultiOptions(); + if (is_bool($inherited)) { + $inherited = $inherited ? 'y' : 'n'; + } + if (is_scalar($inherited) && array_key_exists($inherited, $multi)) { + $multi[null] = $multi[$inherited] . $txtInherited; + } else { + $multi[null] = $this->stringifyInheritedValue($inherited) . $txtInherited; + } + $el->setMultiOptions($multi); + } elseif ($el instanceof ExtensibleSet) { + $el->setAttrib('inherited', $inherited); + $el->setAttrib('inheritedFrom', $inheritedFrom); + } else { + if (is_string($inherited) || is_int($inherited)) { + $el->setAttrib('placeholder', $inherited . $txtInherited); + } + } + + // We inherited a value, so no need to require the field + $el->setRequired(false); + } + + protected function stringifyInheritedValue($value) + { + return is_scalar($value) ? $value : substr(json_encode($value), 0, 40); + } + + public function setListUrl($url) + { + $this->listUrl = $url; + return $this; + } + + public function onSuccess() + { + $object = $this->object(); + if ($object->hasBeenModified()) { + if (! $object->hasBeenLoadedFromDb()) { + $this->setHttpResponseCode(201); + } + + $msg = sprintf( + $object->hasBeenLoadedFromDb() + ? $this->translate('The %s has successfully been stored') + : $this->translate('A new %s has successfully been created'), + $this->translate($this->getObjectShortClassName()) + ); + $this->getDbObjectStore()->store($object); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + $msg = $this->translate('No action taken, object has not been modified'); + } + + $this->setObjectSuccessUrl(); + $this->beforeSuccessfulRedirect(); + $this->redirectOnSuccess($msg); + } + + protected function setObjectSuccessUrl() + { + $object = $this->object(); + + if ($object instanceof IcingaObject) { + $params = $object->getUrlParams(); + $url = Url::fromPath($this->getAction()); + if ($url->hasParam('dbResourceName')) { + $params['dbResourceName'] = $url->getParam('dbResourceName'); + } + $this->setSuccessUrl( + 'director/' . strtolower($this->getObjectShortClassName()), + $params + ); + } elseif ($object->hasProperty('id')) { + $this->setSuccessUrl($this->getSuccessUrl()->with('id', $object->getProperty('id'))); + } + } + + protected function beforeSuccessfulRedirect() + { + } + + public function hasElement($name) + { + return $this->getElement($name) !== null; + } + + public function getObject() + { + return $this->object; + } + + public function hasObject() + { + return $this->object !== null; + } + + public function isIcingaObject() + { + if ($this->object !== null) { + return $this->object instanceof IcingaObject; + } + + /** @var DbObject $class */ + $class = $this->getObjectClassname(); + $instance = $class::create(); + + return $instance instanceof IcingaObject; + } + + public function isMultiObjectForm() + { + return false; + } + + public function setObject(DbObject $object) + { + $this->object = $object; + if ($this->db === null) { + /** @var Db $connection */ + $connection = $object->getConnection(); + $this->setDb($connection); + } + + return $this; + } + + protected function getObjectClassname() + { + if ($this->className === null) { + return 'Icinga\\Module\\Director\\Objects\\' + . substr(join('', array_slice(explode('\\', get_class($this)), -1)), 0, -4); + } + + return $this->className; + } + + protected function getObjectShortClassName() + { + if ($this->objectName === null) { + $className = substr(strrchr(get_class($this), '\\'), 1); + if (substr($className, 0, 6) === 'Icinga') { + return substr($className, 6, -4); + } else { + return substr($className, 0, -4); + } + } + + return $this->objectName; + } + + protected function removeFromSet(&$set, $key) + { + unset($set[$key]); + } + + protected function moveUpInSet(&$set, $key) + { + list($set[$key - 1], $set[$key]) = array($set[$key], $set[$key - 1]); + } + + protected function moveDownInSet(&$set, $key) + { + list($set[$key + 1], $set[$key]) = array($set[$key], $set[$key + 1]); + } + + protected function beforeSetup() + { + if (!$this->hasBeenSent()) { + return; + } + + $post = $values = $this->getRequest()->getPost(); + + foreach ($post as $key => $value) { + if (preg_match('/^(.+?)_(\d+)__(MOVE_DOWN|MOVE_UP|REMOVE)$/', $key, $m)) { + $values[$m[1]] = array_filter($values[$m[1]], 'strlen'); + switch ($m[3]) { + case 'MOVE_UP': + $this->moveUpInSet($values[$m[1]], $m[2]); + break; + case 'MOVE_DOWN': + $this->moveDownInSet($values[$m[1]], $m[2]); + break; + case 'REMOVE': + $this->removeFromSet($values[$m[1]], $m[2]); + break; + } + + $this->getRequest()->setPost($m[1], $values[$m[1]]); + } + } + } + + protected function onRequest() + { + if ($this->object !== null) { + $this->setDefaultsFromObject($this->object); + } + $this->prepareFields($this->object()); + IcingaObjectFormHook::callOnSetup($this); + if ($this->hasBeenSent()) { + $this->handlePost(); + } + try { + $this->loadInheritedProperties(); + $this->addFields(); + $this->callOnRequestCallables(); + } catch (Exception $e) { + $this->addUniqueException($e); + + return; + } + + if ($this->shouldBeDeleted()) { + $this->deleteObject($this->object()); + } + } + + protected function handlePost() + { + $object = $this->object(); + + $post = $this->getRequest()->getPost(); + $this->populate($post); + $values = $this->getValues(); + + if ($object instanceof IcingaObject) { + $this->setCustomVarValues($post); + } + + $this->handleProperties($object, $values); + + // TODO: get rid of this + if ($object instanceof IcingaObject) { + $this->handleRanges($object, $values); + } + } + + protected function setDefaultsFromObject(DbObject $object) + { + /** @var ZfElement $element */ + foreach ($this->getElements() as $element) { + $key = $element->getName(); + if ($object->hasProperty($key)) { + $value = $object->get($key); + if ($object instanceof IcingaObject) { + if ($object->propertyIsRelatedSet($key)) { + if (! count((array) $value)) { + continue; + } + } + } + + if ($value !== null && $value !== []) { + $element->setValue($value); + } + } + } + } + + protected function deleteObject($object) + { + if ($object instanceof IcingaObject && $object->hasProperty('object_name')) { + $msg = sprintf( + '%s "%s" has been removed', + $this->translate($this->getObjectShortClassName()), + $object->getObjectName() + ); + } else { + $msg = sprintf( + '%s has been removed', + $this->translate($this->getObjectShortClassName()) + ); + } + + if ($this->listUrl) { + $url = $this->listUrl; + } elseif ($object instanceof IcingaObject && $object->hasProperty('object_name')) { + $url = $object->getOnDeleteUrl(); + } else { + $url = $this->getSuccessUrl()->without( + array('field_id', 'argument_id', 'range', 'range_type') + ); + } + + if ($this->getDbObjectStore()->delete($object)) { + $this->setSuccessUrl($url); + } + $this->redirectOnSuccess($msg); + } + + /** + * @return DbObjectStore + */ + protected function getDbObjectStore() + { + $store = new DbObjectStore($this->getDb(), $this->branch); + return $store; + } + + protected function addDeleteButton($label = null) + { + $object = $this->object; + + if ($label === null) { + $label = $this->translate('Delete'); + } + + $el = $this->createElement('submit', $label) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + //->removeDecorator('Label'); + + $this->deleteButtonName = $el->getName(); + + if ($object instanceof IcingaObject && $object->isTemplate()) { + if ($cnt = $object->countDirectDescendants()) { + $el->setAttrib('disabled', 'disabled'); + $el->setAttrib( + 'title', + sprintf( + $this->translate('This template is still in use by %d other objects'), + $cnt + ) + ); + } + } elseif ($object instanceof IcingaCommand && $object->isInUse()) { + $el->setAttrib('disabled', 'disabled'); + $el->setAttrib( + 'title', + sprintf( + $this->translate('This Command is still in use by %d other objects'), + $object->countDirectUses() + ) + ); + } + + $this->addElement($el); + + return $this; + } + + public function hasDeleteButton() + { + return $this->deleteButtonName !== null; + } + + public function shouldBeDeleted() + { + if (! $this->hasDeleteButton()) { + return false; + } + + $name = $this->deleteButtonName; + return $this->getSentValue($name) === $this->getElement($name)->getLabel(); + } + + public function abortDeletion() + { + if ($this->hasDeleteButton()) { + $this->setSentValue($this->deleteButtonName, 'ABORTED'); + } + } + + public function getSentOrResolvedObjectValue($name, $default = null) + { + return $this->getSentOrObjectValue($name, $default, true); + } + + public function getSentOrObjectValue($name, $default = null, $resolved = false) + { + // TODO: check whether getSentValue is still needed since element->getValue + // is in place (currently for form element default values only) + + if (!$this->hasObject()) { + if ($this->hasBeenSent()) { + return $this->getSentValue($name, $default); + } else { + if ($name === 'object_type' && $this->preferredObjectType) { + return $this->preferredObjectType; + } + if ($name === 'imports' && $this->presetImports) { + return $this->presetImports; + } + if ($this->valueIsEmpty($val = $this->getValue($name))) { + return $default; + } else { + return $val; + } + } + } + + if ($this->hasBeenSent()) { + if (!$this->valueIsEmpty($value = $this->getSentValue($name))) { + return $value; + } + } + + $object = $this->getObject(); + + if ($object->hasProperty($name)) { + if ($resolved && $object->supportsImports()) { + if ($this->assertResolvedImports()) { + $objectProperty = $object->getResolvedProperty($name); + } else { + $objectProperty = $object->$name; + } + } else { + $objectProperty = $object->$name; + } + } else { + $objectProperty = null; + } + + if ($objectProperty !== null) { + return $objectProperty; + } + + if (($el = $this->getElement($name)) && !$this->valueIsEmpty($val = $el->getValue())) { + return $val; + } + + return $default; + } + + public function loadObject($id) + { + if ($this->branch && $this->branch->isBranch()) { + throw new \RuntimeException('Calling loadObject from form in a branch'); + } + /** @var DbObject $class */ + $class = $this->getObjectClassname(); + if (is_int($id)) { + $this->object = $class::loadWithAutoIncId($id, $this->db); + if ($this->object->getKeyName() === 'id') { + $this->addHidden('id', $id); + } + } else { + $this->object = $class::load($id, $this->db); + } + + + return $this; + } + + protected function addRange($key, $range) + { + $this->addElement('text', 'range_' . $key, array( + 'label' => 'ranges.' . $key, + 'value' => $range->range_value + )); + } + + /** + * @param Db $db + * @return $this + */ + public function setDb(Db $db) + { + if ($this->object !== null) { + $this->object->setConnection($db); + } + + parent::setDb($db); + return $this; + } + + public function optionallyAddFromEnum($enum) + { + return array( + null => $this->translate('- click to add more -') + ) + $enum; + } + + protected function addObjectTypeElement() + { + if (!$this->isNew()) { + return $this; + } + + if ($this->preferredObjectType) { + $this->addHidden('object_type', $this->preferredObjectType); + return $this; + } + + $object = $this->object(); + + if ($object->supportsImports()) { + $templates = $this->enumAllowedTemplates(); + + if (empty($templates) && $this->getObjectShortClassName() !== 'Command') { + $types = array('template' => $this->translate('Template')); + } else { + $types = array( + 'object' => $this->translate('Object'), + 'template' => $this->translate('Template'), + ); + } + } else { + $types = array('object' => $this->translate('Object')); + } + + if ($this->object()->supportsApplyRules()) { + $types['apply'] = $this->translate('Apply rule'); + } + + $this->addElement('select', 'object_type', array( + 'label' => $this->translate('Object type'), + 'description' => $this->translate( + 'What kind of object this should be. Templates allow full access' + . ' to any property, they are your building blocks for "real" objects.' + . ' External objects should usually not be manually created or modified.' + . ' They allow you to work with objects locally defined on your Icinga nodes,' + . ' while not rendering and deploying them with the Director. Apply rules allow' + . ' to assign services, notifications and groups to other objects.' + ), + 'required' => true, + 'multiOptions' => $this->optionalEnum($types), + 'class' => 'autosubmit' + )); + + return $this; + } + + protected function hasObjectType() + { + if (!$this->object()->hasProperty('object_type')) { + return false; + } + + return ! $this->valueIsEmpty($this->getSentOrObjectValue('object_type')); + } + + protected function addZoneElement($all = false) + { + if ($all || $this->isTemplate()) { + $zones = $this->db->enumZones(); + } else { + $zones = $this->db->enumNonglobalZones(); + } + + $this->addElement('select', 'zone_id', array( + 'label' => $this->translate('Cluster Zone'), + 'description' => $this->translate( + 'Icinga cluster zone. Allows to manually override Directors decisions' + . ' of where to deploy your config to. You should consider not doing so' + . ' unless you gained deep understanding of how an Icinga Cluster stack' + . ' works' + ), + 'multiOptions' => $this->optionalEnum($zones) + )); + + return $this; + } + + /** + * @param $type + * @return $this + */ + protected function addChoices($type) + { + if ($this->isTemplate()) { + return $this; + } + + $connection = $this->getDb(); + $choiceType = 'TemplateChoice' . ucfirst($type); + $table = "icinga_$type"; + $choices = IcingaObject::loadAllByType($choiceType, $connection); + $chosenTemplates = $this->getSentOrObjectValue('imports'); + $db = $connection->getDbAdapter(); + if (empty($chosenTemplates)) { + $importedIds = []; + } else { + $importedIds = $db->fetchCol( + $db->select()->from($table, 'id') + ->where('object_name in (?)', (array)$chosenTemplates) + ->where('object_type = ?', 'template') + ); + } + + foreach ($choices as $choice) { + $required = $choice->get('required_template_id'); + if ($required === null || in_array($required, $importedIds, false)) { + $this->addChoiceElement($choice); + } + } + + return $this; + } + + protected function addChoiceElement(IcingaTemplateChoice $choice) + { + $imports = $this->object()->listImportNames(); + $element = $choice->createFormElement($this, $imports); + $this->addElement($element); + $this->choiceElements[$element->getName()] = $element; + return $this; + } + + /** + * @param bool $required + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addImportsElement($required = null) + { + if ($this->presetImports) { + return $this; + } + + if (in_array($this->getObjectShortClassName(), ['TimePeriod', 'ScheduledDowntime'])) { + $required = false; + } else { + $required = $required !== null ? $required : !$this->isTemplate(); + } + $enum = $this->enumAllowedTemplates(); + if (empty($enum)) { + if ($required) { + if ($this->hasBeenSent()) { + $this->addError($this->translate('No template has been chosen')); + } else { + if ($this->hasPermission('director/admin')) { + $html = $this->translate('Please define a related template first'); + } else { + $html = $this->translate('No related template has been provided yet'); + } + $this->addHtml('<p class="warning">' . $html . '</p>'); + } + } + return $this; + } + + $db = $this->getDb()->getDbAdapter(); + $object = $this->object; + if ($object->supportsChoices()) { + $choiceNames = $db->fetchCol( + $db->select()->from( + $this->object()->getTableName(), + 'object_name' + )->where('template_choice_id IS NOT NULL') + ); + } else { + $choiceNames = []; + } + + $type = $object->getShortTableName(); + $this->addElement('extensibleSet', 'imports', array( + 'label' => $this->translate('Imports'), + 'description' => $this->translate( + 'Importable templates, add as many as you want. Please note that order' + . ' matters when importing properties from multiple templates: last one' + . ' wins' + ), + 'required' => $required, + 'spellcheck' => 'false', + 'hideOptions' => $choiceNames, + 'suggest' => "${type}templates", + // 'multiOptions' => $this->optionallyAddFromEnum($enum), + 'sorted' => true, + 'value' => $this->presetImports, + 'class' => 'autosubmit' + )); + + return $this; + } + + protected function addDisabledElement() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addBoolean( + 'disabled', + array( + 'label' => $this->translate('Disabled'), + 'description' => $this->translate('Disabled objects will not be deployed') + ), + 'n' + ); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addGroupDisplayNameElement() + { + $this->addElement('text', 'display_name', array( + 'label' => $this->translate('Display Name'), + 'description' => $this->translate( + 'An alternative display name for this group. If you wonder how this' + . ' could be helpful just leave it blank' + ) + )); + + return $this; + } + + /** + * @param bool $force + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addCheckCommandElements($force = false) + { + if (! $force && ! $this->isTemplate()) { + return $this; + } + + $this->addElement('text', 'check_command', array( + 'label' => $this->translate('Check command'), + 'description' => $this->translate('Check command definition'), + // 'multiOptions' => $this->optionalEnum($this->db->enumCheckcommands()), + 'class' => 'autosubmit director-suggest', // This influences fields + 'data-suggestion-context' => 'checkcommandnames', + 'value' => $this->getObject()->get('check_command') + )); + $this->getDisplayGroup('object_definition') + // ->addElement($this->getElement('check_command_id')) + ->addElement($this->getElement('check_command')); + + $eventCommands = $this->db->enumEventcommands(); + + if (! empty($eventCommands)) { + $this->addElement('select', 'event_command_id', array( + 'label' => $this->translate('Event command'), + 'description' => $this->translate('Event command definition'), + 'multiOptions' => $this->optionalEnum($eventCommands), + 'class' => 'autosubmit', + )); + $this->addToCheckExecutionDisplayGroup('event_command_id'); + } + + return $this; + } + + protected function addCheckExecutionElements($force = false) + { + if (! $force && ! $this->isTemplate()) { + return $this; + } + + $this->addElement( + 'text', + 'check_interval', + array( + 'label' => $this->translate('Check interval'), + 'description' => $this->translate('Your regular check interval') + ) + ); + + $this->addElement( + 'text', + 'retry_interval', + array( + 'label' => $this->translate('Retry interval'), + 'description' => $this->translate( + 'Retry interval, will be applied after a state change unless the next hard state is reached' + ) + ) + ); + + $this->addElement( + 'text', + 'max_check_attempts', + array( + 'label' => $this->translate('Max check attempts'), + 'description' => $this->translate( + 'Defines after how many check attempts a new hard state is reached' + ) + ) + ); + + $this->addElement( + 'text', + 'check_timeout', + array( + 'label' => $this->translate('Check timeout'), + 'description' => $this->translate( + "Check command timeout in seconds. Overrides the CheckCommand's timeout attribute" + ) + ) + ); + + $periods = $this->db->enumTimeperiods(); + + if (!empty($periods)) { + $this->addElement( + 'select', + 'check_period_id', + array( + 'label' => $this->translate('Check period'), + 'description' => $this->translate( + 'The name of a time period which determines when this' + . ' object should be monitored. Not limited by default.' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + } + + $this->optionalBoolean( + 'enable_active_checks', + $this->translate('Execute active checks'), + $this->translate('Whether to actively check this object') + ); + + $this->optionalBoolean( + 'enable_passive_checks', + $this->translate('Accept passive checks'), + $this->translate('Whether to accept passive check results for this object') + ); + + $this->optionalBoolean( + 'enable_notifications', + $this->translate('Send notifications'), + $this->translate('Whether to send notifications for this object') + ); + + $this->optionalBoolean( + 'enable_event_handler', + $this->translate('Enable event handler'), + $this->translate('Whether to enable event handlers this object') + ); + + $this->optionalBoolean( + 'enable_perfdata', + $this->translate('Process performance data'), + $this->translate('Whether to process performance data provided by this object') + ); + + $this->optionalBoolean( + 'enable_flapping', + $this->translate('Enable flap detection'), + $this->translate('Whether flap detection is enabled on this object') + ); + + $this->addElement( + 'text', + 'flapping_threshold_high', + array( + 'label' => $this->translate('Flapping threshold (high)'), + 'description' => $this->translate( + 'Flapping upper bound in percent for a service to be considered flapping' + ) + ) + ); + + $this->addElement( + 'text', + 'flapping_threshold_low', + array( + 'label' => $this->translate('Flapping threshold (low)'), + 'description' => $this->translate( + 'Flapping lower bound in percent for a service to be considered not flapping' + ) + ) + ); + + $this->optionalBoolean( + 'volatile', + $this->translate('Volatile'), + $this->translate('Whether this check is volatile.') + ); + + $elements = array( + 'check_interval', + 'retry_interval', + 'max_check_attempts', + 'check_timeout', + 'check_period_id', + 'enable_active_checks', + 'enable_passive_checks', + 'enable_notifications', + 'enable_event_handler', + 'enable_perfdata', + 'enable_flapping', + 'flapping_threshold_high', + 'flapping_threshold_low', + 'volatile' + ); + $this->addToCheckExecutionDisplayGroup($elements); + + return $this; + } + + protected function enumAllowedTemplates() + { + $object = $this->object(); + $tpl = $this->db->enumIcingaTemplates($object->getShortTableName()); + if (empty($tpl)) { + return []; + } + + $id = $object->get('id'); + + if (array_key_exists($id, $tpl)) { + unset($tpl[$id]); + } + + return array_combine($tpl, $tpl); + } + + protected function addExtraInfoElements() + { + $this->addElement('textarea', 'notes', array( + 'label' => $this->translate('Notes'), + 'description' => $this->translate( + 'Additional notes for this object' + ), + 'rows' => 2, + 'columns' => 60, + )); + + $this->addElement('text', 'notes_url', array( + 'label' => $this->translate('Notes URL'), + 'description' => $this->translate( + 'An URL pointing to additional notes for this object' + ), + )); + + $this->addElement('text', 'action_url', array( + 'label' => $this->translate('Action URL'), + 'description' => $this->translate( + 'An URL leading to additional actions for this object. Often used' + . ' with Icinga Classic, rarely with Icinga Web 2 as it provides' + . ' far better possibilities to integrate addons' + ), + )); + + $this->addElement('text', 'icon_image', array( + 'label' => $this->translate('Icon image'), + 'description' => $this->translate( + 'An URL pointing to an icon for this object. Try "tux.png" for icons' + . ' relative to public/img/icons or "cloud" (no extension) for items' + . ' from the Icinga icon font' + ), + )); + + $this->addElement('text', 'icon_image_alt', array( + 'label' => $this->translate('Icon image alt'), + 'description' => $this->translate( + 'Alternative text to be shown in case above icon is missing' + ), + )); + + $elements = array( + 'notes', + 'notes_url', + 'action_url', + 'icon_image', + 'icon_image_alt', + ); + + $this->addDisplayGroup($elements, 'extrainfo', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_EXTRA_INFO, + 'legend' => $this->translate('Additional properties') + )); + + return $this; + } + + /** + * Add an assign_filter form element + * + * Forms should use this helper method for objects using the typical + * assign_filter column + * + * @param array $properties Form element properties + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addAssignFilter($properties) + { + if (!$this->object || !$this->object->supportsAssignments()) { + return $this; + } + + $this->addFilterElement('assign_filter', $properties); + $el = $this->getElement('assign_filter'); + + $this->addDisplayGroup(array($el), 'assign', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_ASSIGN, + 'legend' => $this->translate('Assign where') + )); + + return $this; + } + + /** + * Add a dataFilter element with fitting decorators + * + * TODO: Evaluate whether parts or all of this could be moved to the element + * class. + * + * @param string $name Element name + * @param array $properties Form element properties + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addFilterElement($name, $properties) + { + $this->addElement('dataFilter', $name, $properties); + $el = $this->getElement($name); + + $ddClass = 'full-width'; + if (array_key_exists('required', $properties) && $properties['required']) { + $ddClass .= ' required'; + } + + $el->clearDecorators() + ->addDecorator('ViewHelper') + ->addDecorator('Errors') + ->addDecorator('Description', array('tag' => 'p', 'class' => 'description')) + ->addDecorator('HtmlTag', array( + 'tag' => 'dd', + 'class' => $ddClass, + )); + + return $this; + } + + protected function addEventFilterElements($elements = array('states','types')) + { + if (in_array('states', $elements)) { + $this->addElement('extensibleSet', 'states', array( + 'label' => $this->translate('States'), + 'multiOptions' => $this->optionallyAddFromEnum($this->enumStates()), + 'description' => $this->translate( + 'The host/service states you want to get notifications for' + ), + )); + } + + if (in_array('types', $elements)) { + $this->addElement('extensibleSet', 'types', array( + 'label' => $this->translate('Transition types'), + 'multiOptions' => $this->optionallyAddFromEnum($this->enumTypes()), + 'description' => $this->translate( + 'The state transition types you want to get notifications for' + ), + )); + } + + $this->addDisplayGroup($elements, 'event_filters', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_EVENT_FILTERS, + 'legend' => $this->translate('State and transition type filters') + )); + + return $this; + } + + /** + * @param string $permission + * @return bool + */ + public function hasPermission($permission) + { + return Util::hasPermission($permission); + } + + public function setBranch(Branch $branch) + { + $this->branch = $branch; + + return $this; + } + + protected function allowsExperimental() + { + // NO, it is NOT a good idea to use this. You'll break your monitoring + // and nobody will help you. + if ($this->allowsExperimental === null) { + $this->allowsExperimental = $this->db->settings()->get( + 'experimental_features' + ) === 'allow'; + } + + return $this->allowsExperimental; + } + + protected function enumStates() + { + $set = new StateFilterSet(); + return $set->enumAllowedValues(); + } + + protected function enumTypes() + { + $set = new TypeFilterSet(); + return $set->enumAllowedValues(); + } +} diff --git a/library/Director/Web/Form/Element/Boolean.php b/library/Director/Web/Form/Element/Boolean.php new file mode 100644 index 0000000..b2402c7 --- /dev/null +++ b/library/Director/Web/Form/Element/Boolean.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Select as ZfSelect; + +/** + * Input control for booleans + */ +class Boolean extends ZfSelect +{ + public $options = array( + null => '- please choose -', + 'y' => 'Yes', + 'n' => 'No', + ); + + public function getValue() + { + $value = $this->getUnfilteredValue(); + + if ($value === 'y' || $value === true) { + return true; + } elseif ($value === 'n' || $value === false) { + return false; + } + + return null; + } + + public function isValid($value, $context = null) + { + if ($value === 'y' || $value === 'n') { + $this->setValue($value); + return true; + } + + return parent::isValid($value, $context); + } + + /** + * @param string $value + * @param string $key + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + if ($value === true) { + $value = 'y'; + } elseif ($value === false) { + $value = 'n'; + } elseif ($value === '') { + $value = null; + } + + parent::_filterValue($value, $key); + } + + public function setValue($value) + { + if ($value === true) { + $value = 'y'; + } elseif ($value === false) { + $value = 'n'; + } elseif ($value === '') { + $value = null; + } + + return parent::setValue($value); + } + + /** + * @codingStandardsIgnoreStart + */ + protected function _translateOption($option, $value) + { + // @codingStandardsIgnoreEnd + if (!isset($this->_translated[$option]) && !empty($value)) { + $this->options[$option] = mt('director', $value); + if ($this->options[$option] === $value) { + return false; + } + $this->_translated[$option] = true; + return true; + } + + return false; + } +} diff --git a/library/Director/Web/Form/Element/DataFilter.php b/library/Director/Web/Form/Element/DataFilter.php new file mode 100644 index 0000000..adae07d --- /dev/null +++ b/library/Director/Web/Form/Element/DataFilter.php @@ -0,0 +1,361 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Web\Form\IconHelper; +use Exception; + +/** + * Input control for extensible sets + */ +class DataFilter extends FormElement +{ + /** + * Default form view helper to use for rendering + * @var string + */ + public $helper = 'formDataFilter'; + + private $addTo; + + private $removeFilter; + + private $stripFilter; + + /** @var FilterChain */ + private $filter; + + public function getValue() + { + $value = parent::getValue(); + if ($value !== null && $this->isEmpty($value)) { + $value = null; + } + + return $value; + } + + protected function isEmpty(Filter $filter) + { + return $filter->isEmpty() || $this->isEmptyExpression($filter); + } + + protected function isEmptyExpression(Filter $filter) + { + return $filter instanceof FilterExpression && + $filter->getColumn() === '' && + $filter->getExpression() === '""'; // -> json_encode('') + } + + /** + * @inheritdoc + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + try { + if ($value instanceof Filter) { + // OK + } elseif (is_string($value)) { + $value = Filter::fromQueryString($value); + } elseif (is_array($value) || is_null($value)) { + $value = $this->arrayToFilter($value); + } else { + throw new ProgrammingError( + 'Value to be filtered has to be Filter, string, array or null' + ); + } + } catch (Exception $e) { + $value = null; + // TODO: getFile, getLine + // Hint: cannot addMessage at it would loop through getValue + $this->addErrorMessage($e->getMessage()); + $this->_isErrorForced = true; + } + } + + /** + * This method transforms filter form data into a filter + * and reacts on pressed buttons + * + * @param array|null $array + * + * @return FilterChain|null + */ + protected function arrayToFilter($array) + { + if ($array === null) { + return null; + } + + $this->filter = null; + foreach ($array as $id => $entry) { + $filterId = $this->idToFilterId($id); + $sub = $this->entryToFilter($entry); + $this->checkEntryForActions($filterId, $entry); + $parentId = $this->parentIdFor($filterId); + + if ($this->filter === null) { + $this->filter = $sub; + } else { + $this->filter->getById($parentId)->addFilter($sub); + } + } + + $this->removeFilterIfRequested() + ->stripFilterIfRequested() + ->addNewFilterIfRequested() + ->fixNotsWithMultipleChildren(); + + return $this->filter; + } + + protected function removeFilterIfRequested() + { + if ($this->removeFilter !== null) { + if ($this->filter->getById($this->removeFilter)->isRootNode()) { + $this->filter = $this->emptyExpression(); + } else { + $this->filter->removeId($this->removeFilter); + } + } + + return $this; + } + + + protected function stripFilterIfRequested() + { + if ($this->stripFilter !== null) { + $strip = $this->stripFilter; + $subId = $strip . '-1'; + if ($this->filter->getId() === $strip) { + $this->filter = $this->filter->getById($subId); + } else { + $this->filter->replaceById($strip, $this->filter->getById($subId)); + } + } + + return $this; + } + + protected function addNewFilterIfRequested() + { + if ($this->addTo !== null) { + $parent = $this->filter->getById($this->addTo); + + if ($parent instanceof FilterChain) { + if ($parent->isEmpty()) { + $parent->addFilter($this->emptyExpression()); + } else { + $parent->addFilter($this->emptyExpression()); + } + } elseif ($parent instanceof FilterExpression) { + $replacement = Filter::matchAll(clone($parent)); + if ($parent->isRootNode()) { + $this->filter = $replacement; + } else { + $this->filter->replaceById($parent->getId(), $replacement); + } + } + } + + return $this; + } + + protected function fixNotsWithMultipleChildren() + { + $this->filter = $this->fixNotsWithMultipleChildrenForFilter($this->filter); + return $this; + } + + protected function fixNotsWithMultipleChildrenForFilter(Filter $filter) + { + if ($filter instanceof FilterChain) { + if ($filter->getOperatorName() === 'NOT') { + if ($filter->count() > 1) { + $filter = $this->notToNotAnd($filter); + } + } + /** @var Filter $sub */ + foreach ($filter->filters() as $sub) { + $filter->replaceById( + $sub->getId(), + $this->fixNotsWithMultipleChildrenForFilter($sub) + ); + } + } + + return $filter; + } + + protected function notToNotAnd(FilterChain $not) + { + $and = Filter::matchAll(); + foreach ($not->filters() as $sub) { + $and->addFilter(clone($sub)); + } + + return Filter::not($and); + } + + protected function emptyExpression() + { + return Filter::expression('', '=', ''); + } + + protected function parentIdFor($id) + { + if (false === ($pos = strrpos($id, '-'))) { + return '0'; + } else { + return substr($id, 0, $pos); + } + } + + protected function idToFilterId($id) + { + if (! preg_match('/^id_(new_)?(\d+(?:-\d+)*)$/', $id, $m)) { + die('nono' . $id); + } + + return $m[2]; + } + + protected function checkEntryForActions($filterId, $entry) + { + switch ($this->entryAction($entry)) { + case 'cancel': + $this->removeFilter = $filterId; + break; + + case 'minus': + $this->stripFilter = $filterId; + break; + + case 'plus': + case 'angle-double-right': + $this->addTo = $filterId; + break; + } + } + + /** + * Transforms a single submitted form component from an array + * into a Filter object + * + * @param array $entry The array as submitted through the form + * + * @return Filter + */ + protected function entryToFilter($entry) + { + if (array_key_exists('operator', $entry)) { + return Filter::chain($entry['operator']); + } else { + return $this->entryToFilterExpression($entry); + } + } + + protected function entryToFilterExpression($entry) + { + if ($entry['sign'] === 'true') { + return Filter::expression( + $entry['column'], + '=', + json_encode(true) + ); + } elseif ($entry['sign'] === 'false') { + return Filter::expression( + $entry['column'], + '=', + json_encode(false) + ); + } elseif ($entry['sign'] === 'in') { + if (array_key_exists('value', $entry)) { + if (is_array($entry['value'])) { + $value = array_filter($entry['value'], 'strlen'); + } elseif (empty($entry['value'])) { + $value = array(); + } else { + $value = array($entry['value']); + } + } else { + $value = array(); + } + return Filter::expression( + $entry['column'], + '=', + json_encode($value) + ); + } elseif ($entry['sign'] === 'contains') { + $value = array_key_exists('value', $entry) ? $entry['value'] : null; + + return Filter::expression( + json_encode($value), + '=', + $entry['column'] + ); + } else { + $value = array_key_exists('value', $entry) ? $entry['value'] : null; + + return Filter::expression( + $entry['column'], + $entry['sign'], + json_encode($value) + ); + } + } + + protected function entryAction($entry) + { + if (array_key_exists('action', $entry)) { + return IconHelper::instance()->characterIconName($entry['action']); + } + + return null; + } + + protected function hasIncompleteExpressions(Filter $filter) + { + if ($filter instanceof FilterChain) { + foreach ($filter->filters() as $sub) { + if ($this->hasIncompleteExpressions($sub)) { + return true; + } + } + + return false; + } else { + /** @var FilterExpression $filter */ + if ($filter->isRootNode() && $this->isEmptyExpression($filter)) { + return false; + } + + return $filter->getColumn() === ''; + } + } + + public function isValid($value, $context = null) + { + if (! $value instanceof Filter) { + // TODO: try, return false on E + $filter = $this->arrayToFilter($value); + $this->setValue($filter); + } else { + $filter = $value; + } + + if ($this->hasIncompleteExpressions($filter)) { + $this->addError('The configured filter is incomplete'); + return false; + } + + return parent::isValid($value); + } +} diff --git a/library/Director/Web/Form/Element/ExtensibleSet.php b/library/Director/Web/Form/Element/ExtensibleSet.php new file mode 100644 index 0000000..f3c968f --- /dev/null +++ b/library/Director/Web/Form/Element/ExtensibleSet.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use InvalidArgumentException; + +/** + * Input control for extensible sets + */ +class ExtensibleSet extends FormElement +{ + /** + * Default form view helper to use for rendering + * @var string + */ + public $helper = 'formIplExtensibleSet'; + + // private $multiOptions; + + public function getValue() + { + $value = parent::getValue(); + if (is_string($value) || is_numeric($value)) { + $value = [$value]; + } elseif ($value === null) { + return $value; + } + if (! is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'ExtensibleSet expects to work with Arrays, got %s', + var_export($value, 1) + )); + } + $value = array_filter($value, 'strlen'); + + if (empty($value)) { + return null; + } + + return $value; + } + + /** + * We do not want one message per entry + * + * @codingStandardsIgnoreStart + */ + protected function _getErrorMessages() + { + return $this->_errorMessages; + // @codingStandardsIgnoreEnd + } + + /** + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + if (is_array($value)) { + $value = array_filter($value, 'strlen'); + } elseif (is_string($value) && !strlen($value)) { + $value = null; + } + + parent::_filterValue($value, $key); + } + + public function isValid($value, $context = null) + { + if ($value === null) { + $value = []; + } + + $value = array_filter($value, 'strlen'); + $this->setValue($value); + if ($this->isRequired() && empty($value)) { + // TODO: translate + $this->addError('You are required to choose at least one element'); + return false; + } + + if ($this->hasErrors()) { + return false; + } + + return parent::isValid($value, $context); + } +} diff --git a/library/Director/Web/Form/Element/FormElement.php b/library/Director/Web/Form/Element/FormElement.php new file mode 100644 index 0000000..c327859 --- /dev/null +++ b/library/Director/Web/Form/Element/FormElement.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Xhtml; + +class FormElement extends Zend_Form_Element_Xhtml +{ +} diff --git a/library/Director/Web/Form/Element/InstanceSummary.php b/library/Director/Web/Form/Element/InstanceSummary.php new file mode 100644 index 0000000..722ad26 --- /dev/null +++ b/library/Director/Web/Form/Element/InstanceSummary.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use gipfl\IcingaWeb2\Link; +use ipl\Html\Html; + +/** + * Used by the + */ +class InstanceSummary extends FormElement +{ + public $helper = 'formSimpleNote'; + + /** + * Always ignore this element + * @codingStandardsIgnoreStart + * + * @var boolean + */ + protected $_ignore = true; + // @codingStandardsIgnoreEnd + + private $instances; + + /** @var array will be set via options */ + protected $linkParams; + + public function setValue($value) + { + $this->instances = $value; + return $this; + } + + public function getValue() + { + return Html::tag('span', [ + Html::tag('italic', 'empty'), + ' ', + Link::create('Manage Instances', 'director/data/dictionary', $this->linkParams, [ + 'data-base-target' => '_next', + 'class' => 'icon-forward' + ]) + ]); + } + + public function isValid($value, $context = null) + { + return true; + } +} diff --git a/library/Director/Web/Form/Element/OptionalYesNo.php b/library/Director/Web/Form/Element/OptionalYesNo.php new file mode 100644 index 0000000..7ef6d7f --- /dev/null +++ b/library/Director/Web/Form/Element/OptionalYesNo.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +/** + * Input control for booleans, gives y/n + */ +class OptionalYesNo extends Boolean +{ + public function getValue() + { + $value = $this->getUnfilteredValue(); + + if ($value === 'y' || $value === true) { + return 'y'; + } elseif ($value === 'n' || $value === false) { + return 'n'; + } + + return null; + } +} diff --git a/library/Director/Web/Form/Element/SimpleNote.php b/library/Director/Web/Form/Element/SimpleNote.php new file mode 100644 index 0000000..3097e11 --- /dev/null +++ b/library/Director/Web/Form/Element/SimpleNote.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Icinga\Module\Director\PlainObjectRenderer; +use ipl\Html\ValidHtml; + +class SimpleNote extends FormElement +{ + public $helper = 'formSimpleNote'; + + /** + * Always ignore this element + * @codingStandardsIgnoreStart + * + * @var boolean + */ + protected $_ignore = true; + // @codingStandardsIgnoreEnd + + public function isValid($value, $context = null) + { + return true; + } + + public function setValue($value) + { + if (is_object($value) && ! $value instanceof ValidHtml) { + $value = 'Unexpected object: ' . PlainObjectRenderer::render($value); + } + + return parent::setValue($value); + } +} diff --git a/library/Director/Web/Form/Element/StoredPassword.php b/library/Director/Web/Form/Element/StoredPassword.php new file mode 100644 index 0000000..fa0545b --- /dev/null +++ b/library/Director/Web/Form/Element/StoredPassword.php @@ -0,0 +1,62 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Text as ZfText; + +/** + * StoredPassword + * + * This is a special form field and it might look a little bit weird at first + * sight. It's main use-case are stored cleartext passwords a user should be + * allowed to change. + * + * While this might sound simple, it's quite tricky if you try to fulfill the + * following requirements: + * + * - the current password should not be rendered to the HTML page (unless the + * user decides to change it) + * - it must be possible to visually distinct whether a password has been set + * - it should be impossible to "see" the length of the stored password + * - a changed password must be persisted + * - forms might be subject to multiple submissions in case other fields fail. + * If the user changed the password during the first submission attempt, the + * new string should not be lost. + * - all this must happen within the bounds of ZF1 form elements and related + * view helpers. This means that there is no related context available - and + * we do not know whether the form has been submitted and whether the current + * values have been populated from DB + * + * @package Icinga\Module\Director\Web\Form\Element + */ +class StoredPassword extends ZfText +{ + const UNCHANGED = '__UNCHANGED_VALUE__'; + + public $helper = 'formStoredPassword'; + + public function setValue($value) + { + if (\is_array($value) && isset($value['_value'], $value['_sent']) + && $value['_sent'] === 'y' + ) { + $value = $sentValue = $value['_value']; + if ($sentValue !== self::UNCHANGED) { + $this->setAttrib('sentValue', $sentValue); + } + } else { + $sentValue = null; + } + + if ($value === self::UNCHANGED) { + return $this; + } else { + // Workaround for issue with modified DataTypes. This is Director-specific + if (\is_array($value)) { + $value = \json_encode($value); + } + + return parent::setValue((string) $value); + } + } +} diff --git a/library/Director/Web/Form/Element/Text.php b/library/Director/Web/Form/Element/Text.php new file mode 100644 index 0000000..eeb36f1 --- /dev/null +++ b/library/Director/Web/Form/Element/Text.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Text as ZfText; + +class Text extends ZfText +{ + public function setValue($value) + { + if (\is_array($value)) { + $value = \json_encode($value); + } + return parent::setValue((string) $value); + } +} diff --git a/library/Director/Web/Form/Element/YesNo.php b/library/Director/Web/Form/Element/YesNo.php new file mode 100644 index 0000000..3e8aaa7 --- /dev/null +++ b/library/Director/Web/Form/Element/YesNo.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +/** + * Input control for booleans, gives y/n + */ +class YesNo extends OptionalYesNo +{ + public $options = array( + 'y' => 'Yes', + 'n' => 'No', + ); +} diff --git a/library/Director/Web/Form/Filter/QueryColumnsFromSql.php b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php new file mode 100644 index 0000000..6f6d475 --- /dev/null +++ b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Filter; + +use Exception; +use Icinga\Data\Db\DbConnection; +use Icinga\Module\Director\Forms\ImportSourceForm; +use Zend_Filter_Interface; + +class QueryColumnsFromSql implements Zend_Filter_Interface +{ + /** @var ImportSourceForm */ + private $form; + + public function __construct(ImportSourceForm $form) + { + $this->form = $form; + } + + public function filter($value) + { + $form = $this->form; + if (empty($value) || $form->hasChangedSetting('query')) { + try { + return implode( + ', ', + $this->getQueryColumns($form->getSentOrObjectSetting('query')) + ); + } catch (Exception $e) { + $this->form->addUniqueException($e); + return ''; + } + } else { + return $value; + } + } + + protected function getQueryColumns($query) + { + $resourceName = $this->form->getSentOrObjectSetting('resource'); + if (! $resourceName) { + return []; + } + $db = DbConnection::fromResourceName($resourceName)->getDbAdapter(); + + return array_keys((array) current($db->fetchAll($query))); + } +} diff --git a/library/Director/Web/Form/FormLoader.php b/library/Director/Web/Form/FormLoader.php new file mode 100644 index 0000000..ea82857 --- /dev/null +++ b/library/Director/Web/Form/FormLoader.php @@ -0,0 +1,43 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use Icinga\Exception\ProgrammingError; +use RuntimeException; + +class FormLoader +{ + public static function load($name, Module $module = null) + { + if ($module === null) { + try { + $basedir = Icinga::app()->getApplicationDir('forms'); + } catch (ProgrammingError $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + $ns = '\\Icinga\\Web\\Forms\\'; + } else { + $basedir = $module->getFormDir(); + $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\'; + } + if (preg_match('~^[a-z0-9/]+$~i', $name)) { + $parts = preg_split('~/~', $name); + $class = ucfirst(array_pop($parts)) . 'Form'; + $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class); + if (file_exists($file)) { + require_once($file); + $class = $ns . $class; + $options = array(); + if ($module !== null) { + $options['icingaModule'] = $module; + } + + return new $class($options); + } + } + + throw new RuntimeException(sprintf('Cannot load %s (%s), no such form', $name, $file)); + } +} diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php new file mode 100644 index 0000000..c900edf --- /dev/null +++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php @@ -0,0 +1,628 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Hook\HostFieldHook; +use Icinga\Module\Director\Hook\ServiceFieldHook; +use Icinga\Module\Director\Objects\DirectorDatafieldCategory; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\ObjectApplyMatches; +use Icinga\Web\Hook; +use stdClass; +use Zend_Db_Select as ZfSelect; +use Zend_Form_Element as ZfElement; + +class IcingaObjectFieldLoader +{ + protected $form; + + /** @var IcingaObject */ + protected $object; + + /** @var \Icinga\Module\Director\Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var DirectorDatafield[] */ + protected $fields; + + protected $elements; + + protected $forceNull = array(); + + /** @var array Map element names to variable names 'elName' => 'varName' */ + protected $nameMap = array(); + + public function __construct(IcingaObject $object) + { + $this->object = $object; + $this->connection = $object->getConnection(); + $this->db = $this->connection->getDbAdapter(); + } + + public function addFieldsToForm(DirectorObjectForm $form) + { + if ($this->fields || $this->object->supportsFields()) { + $this->attachFieldsToForm($form); + } + + return $this; + } + + public function loadFieldsForMultipleObjects($objects) + { + $fields = array(); + foreach ($objects as $object) { + foreach ($this->prepareObjectFields($object) as $varname => $field) { + $varname = $field->get('varname'); + if (array_key_exists($varname, $fields)) { + if ($field->get('datatype') !== $fields[$varname]->datatype) { + unset($fields[$varname]); + } + + continue; + } + + $fields[$varname] = $field; + } + } + + $this->fields = $fields; + + return $this; + } + + /** + * Set a list of values + * + * Works in a fail-safe way, when a field does not exist the value will be + * silently ignored + * + * @param array $values key/value pairs with variable names and their value + * @param string $prefix An optional prefix that would be stripped from keys + * + * @return IcingaObjectFieldLoader + * + * @throws IcingaException + */ + public function setValues($values, $prefix = null) + { + if (! $this->object->supportsCustomVars()) { + return $this; + } + + if ($prefix === null) { + $len = null; + } else { + $len = strlen($prefix); + } + $vars = $this->object->vars(); + + foreach ($values as $key => $value) { + if ($len !== null) { + if (substr($key, 0, $len) === $prefix) { + $key = substr($key, $len); + } else { + continue; + } + } + + $varName = $this->getElementVarName($prefix . $key); + if ($varName === null) { + // throw new IcingaException( + // 'Cannot set variable value for "%s", got no such element', + // $key + // ); + + // Silently ignore additional fields. One might have switched + // template or command + continue; + } + + $el = $this->getElement($varName); + if ($el === null) { + // throw new IcingaException('No such element %s', $key); + // Same here. + continue; + } + + $el->setValue($value); + $value = $el->getValue(); + if ($value === '' || $value === array()) { + $value = null; + } + + $vars->set($varName, $value); + } + + // Hint: this does currently not happen, as removeFilteredFields did not + // take place yet. This has been added to be on the safe side when + // cleaning things up one future day + foreach ($this->forceNull as $key) { + $vars->set($key, null); + } + + return $this; + } + + /** + * Get the fields for our object + * + * @return DirectorDatafield[] + */ + public function getFields() + { + if ($this->fields === null) { + $this->fields = $this->prepareObjectFields($this->object); + } + + return $this->fields; + } + + /** + * Get the form elements for our fields + * + * @param DirectorObjectForm $form Optional + * + * @return ZfElement[] + */ + public function getElements(DirectorObjectForm $form = null) + { + if ($this->elements === null) { + $this->elements = $this->createElements($form); + $this->setValuesFromObject($this->object); + } + + return $this->elements; + } + + /** + * Prepare the form elements for our fields + * + * @param DirectorObjectForm $form Optional + * + * @return self + */ + public function prepareElements(DirectorObjectForm $form = null) + { + if ($this->object->supportsFields()) { + $this->getElements($form); + } + + return $this; + } + + /** + * Attach our form fields to the given form + * + * This will also create a 'Custom properties' display group + * + * @param DirectorObjectForm $form + */ + protected function attachFieldsToForm(DirectorObjectForm $form) + { + if ($this->fields === null) { + return; + } + $elements = $this->removeFilteredFields($this->getElements($form)); + + foreach ($elements as $element) { + $form->addElement($element); + } + + $this->attachGroupElements($elements, $form); + } + + /** + * @param ZfElement[] $elements + * @param DirectorObjectForm $form + */ + protected function attachGroupElements(array $elements, DirectorObjectForm $form) + { + $categories = []; + $categoriesFetchedById = []; + foreach ($this->fields as $key => $field) { + if ($id = $field->get('category_id')) { + if (isset($categoriesFetchedById[$id])) { + $category = $categoriesFetchedById[$id]; + } else { + $category = DirectorDatafieldCategory::loadWithAutoIncId($id, $form->getDb()); + $categoriesFetchedById[$id] = $category; + } + } elseif ($field->hasCategory()) { + $category = $field->getCategory(); + } else { + continue; + } + $categories[$key] = $category; + } + $prioIdx = \array_flip(\array_keys($categories)); + + foreach ($elements as $key => $element) { + if (isset($categories[$key])) { + $category = $categories[$key]; + $form->addElementsToGroup( + [$element], + 'custom_fields:' . $category->get('category_name'), + DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELD_CATEGORIES + $prioIdx[$key], + $category->get('category_name') + ); + } else { + $form->addElementsToGroup( + [$element], + 'custom_fields', + DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELDS, + $form->translate('Custom properties') + ); + } + } + } + + /** + * @param ZfElement[] $elements + * @return ZfElement[] + */ + protected function removeFilteredFields(array $elements) + { + $filters = array(); + foreach ($this->fields as $key => $field) { + if ($filter = $field->var_filter) { + $filters[$key] = Filter::fromQueryString($filter); + } + } + + $kill = array(); + $columns = array(); + $object = $this->object; + if ($object instanceof IcingaHost) { + $prefix = 'host.vars.'; + } elseif ($object instanceof IcingaService) { + $prefix = 'service.vars.'; + } else { + return $elements; + } + + $object->invalidateResolveCache(); + $vars = $object::fromPlainObject( + $object->toPlainObject(true), + $object->getConnection() + )->getVars(); + + $prefixedVars = (object) array(); + foreach ($vars as $k => $v) { + $prefixedVars->{$prefix . $k} = $v; + } + + foreach ($filters as $key => $filter) { + ObjectApplyMatches::fixFilterColumns($filter); + /** @var $filter FilterChain|FilterExpression */ + foreach ($filter->listFilteredColumns() as $column) { + $column = substr($column, strlen($prefix)); + $columns[$column] = $column; + } + if (! $filter->matches($prefixedVars)) { + $kill[] = $key; + } + } + + $vars = $object->vars(); + foreach ($kill as $key) { + unset($elements[$key]); + $this->forceNull[$key] = $key; + // Hint: this should happen later on, currently execution order is + // a little bit weird + $vars->set($key, null); + } + + foreach ($columns as $col) { + if (array_key_exists($col, $elements)) { + $el = $elements[$col]; + $existingClass = $el->getAttrib('class'); + if ($existingClass !== null && strlen($existingClass)) { + $el->setAttrib('class', $existingClass . ' autosubmit'); + } else { + $el->setAttrib('class', 'autosubmit'); + } + } + } + + return $elements; + } + + protected function getElementVarName($name) + { + if (array_key_exists($name, $this->nameMap)) { + return $this->nameMap[$name]; + } + + return null; + } + + /** + * Get the form element for a specific field by it's variable name + * + * @param string $name + * @return null|ZfElement + */ + protected function getElement($name) + { + $elements = $this->getElements(); + if (array_key_exists($name, $elements)) { + return $this->elements[$name]; + } + + return null; + } + + /** + * Get the form elements based on the given form + * + * @param DirectorObjectForm $form + * + * @return ZfElement[] + */ + protected function createElements(DirectorObjectForm $form) + { + $elements = array(); + + foreach ($this->getFields() as $name => $field) { + $el = $field->getFormElement($form); + $elName = $el->getName(); + if (array_key_exists($elName, $this->nameMap)) { + $form->addErrorMessage(sprintf( + 'Form element name collision, "%s" resolves to "%s", but this is also used for "%s"', + $name, + $elName, + $this->nameMap[$elName] + )); + } + $this->nameMap[$elName] = $name; + $elements[$name] = $el; + } + + return $elements; + } + + /** + * @param IcingaObject $object + */ + protected function setValuesFromObject(IcingaObject $object) + { + foreach ($object->getVars() as $k => $v) { + if ($v !== null && $el = $this->getElement($k)) { + $el->setValue($v); + } + } + } + + protected function mergeFields($listOfFields) + { + // TODO: Merge field for different object, mostly sets + } + + /** + * Create the fields for our object + * + * @param IcingaObject $object + * @return DirectorDatafield[] + */ + protected function prepareObjectFields($object) + { + $fields = $this->loadResolvedFieldsForObject($object); + if ($object->hasRelation('check_command')) { + try { + /** @var IcingaCommand $command */ + $command = $object->getResolvedRelated('check_command'); + } catch (Exception $e) { + // Ignore failures + $command = null; + } + + if ($command) { + $cmdLoader = new static($command); + $cmdFields = $cmdLoader->getFields(); + foreach ($cmdFields as $varname => $field) { + if (! array_key_exists($varname, $fields)) { + $fields[$varname] = $field; + } + } + } + + // TODO -> filters! + } + + return $fields; + } + + /** + * Create the fields for our object + * + * Follows the inheritance logic, resolves all fields and keeps the most + * specific ones. Returns a list of fields indexed by variable name + * + * @param IcingaObject $object + * + * @return DirectorDatafield[] + */ + protected function loadResolvedFieldsForObject(IcingaObject $object) + { + $result = $this->loadDataFieldsForObject( + $object + ); + + $fields = array(); + foreach ($result as $objectId => $varFields) { + foreach ($varFields as $var => $field) { + $fields[$var] = $field; + } + } + + return $fields; + } + + /** + * @param IcingaObject[] $objectList List of objects + * + * @return array + */ + protected function getIdsForObjectList($objectList) + { + $ids = []; + foreach ($objectList as $object) { + if ($object->hasBeenLoadedFromDb()) { + $ids[] = $object->get('id'); + } + } + + return $ids; + } + + public function fetchFieldDetailsForObject(IcingaObject $object) + { + $ids = $object->listAncestorIds(); + if ($id = $object->getProperty('id')) { + $ids[] = $id; + } + return $this->fetchFieldDetailsForIds($ids); + } + + /*** + * @param $objectIds + * + * @return \stdClass[] + */ + protected function fetchFieldDetailsForIds($objectIds) + { + if (empty($objectIds)) { + return []; + } + + $query = $this->prepareSelectForIds($objectIds); + return $this->db->fetchAll($query); + } + + /** + * @param array $ids + * + * @return ZfSelect + */ + protected function prepareSelectForIds(array $ids) + { + $object = $this->object; + + $idColumn = 'f.' . $object->getShortTableName() . '_id'; + + $query = $this->db->select()->from( + array('df' => 'director_datafield'), + array( + 'object_id' => $idColumn, + 'icinga_type' => "('" . $object->getShortTableName() . "')", + 'var_filter' => 'f.var_filter', + 'is_required' => 'f.is_required', + 'id' => 'df.id', + 'category_id' => 'df.category_id', + 'varname' => 'df.varname', + 'caption' => 'df.caption', + 'description' => 'df.description', + 'datatype' => 'df.datatype', + 'format' => 'df.format', + ) + )->join( + array('f' => $object->getTableName() . '_field'), + 'df.id = f.datafield_id', + array() + )->where($idColumn . ' IN (?)', $ids) + ->order('CASE WHEN var_filter IS NULL THEN 0 ELSE 1 END ASC') + ->order('df.caption ASC'); + + return $query; + } + + /** + * Fetches fields for a given object + * + * Gives a list indexed by object id, with each entry being a list of that + * objects DirectorDatafield instances indexed by variable name + * + * @param IcingaObject $object + * + * @return array + */ + public function loadDataFieldsForObject(IcingaObject $object) + { + $res = $this->fetchFieldDetailsForObject($object); + + $result = []; + foreach ($res as $r) { + $id = $r->object_id; + unset($r->object_id); + if (! array_key_exists($id, $result)) { + $result[$id] = new stdClass; + } + + $result[$id]->{$r->varname} = DirectorDatafield::fromDbRow( + $r, + $this->connection + ); + } + + foreach ($this->loadHookedDataFieldForObject($object) as $id => $fields) { + if (array_key_exists($id, $result)) { + foreach ($fields as $varName => $field) { + $result[$id]->$varName = $field; + } + } else { + $result[$id] = $fields; + } + } + + return $result; + } + + /** + * @param IcingaObject $object + * @return array + */ + protected function loadHookedDataFieldForObject(IcingaObject $object) + { + $fields = []; + if ($object instanceof IcingaHost || $object instanceof IcingaService) { + $fields = $this->addHookedFields($object); + } + + return $fields; + } + + /** + * @param IcingaObject $object + * @return mixed + */ + protected function addHookedFields(IcingaObject $object) + { + $fields = []; + /** @var HostFieldHook|ServiceFieldHook $hook */ + $type = ucfirst($object->getShortTableName()); + foreach (Hook::all("Director\\${type}Field") as $hook) { + if ($hook->wants($object)) { + $id = $object->get('id'); + $spec = $hook->getFieldSpec($object); + if (!array_key_exists($id, $fields)) { + $fields[$id] = new stdClass(); + } + $fields[$id]->{$spec->getVarName()} = $spec->toDataField($object); + } + } + return $fields; + } +} diff --git a/library/Director/Web/Form/IconHelper.php b/library/Director/Web/Form/IconHelper.php new file mode 100644 index 0000000..3add09b --- /dev/null +++ b/library/Director/Web/Form/IconHelper.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Exception\ProgrammingError; + +/** + * Icon helper class + * + * Should help to reduce redundant icon-lookup code. Currently with hardcoded + * icons only, could easily provide support for all of them as follows: + * + * $confFile = Icinga::app() + * ->getApplicationDir('fonts/fontello-ifont/config.json'); + * + * $font = json_decode(file_get_contents($confFile)); + * // 'icon-' is to be found in $font->css_prefix_text + * foreach ($font->glyphs as $icon) { + * // $icon->css (= 'help') -> 0x . dechex($icon->code) + * } + */ +class IconHelper +{ + private $icons = array( + 'minus' => 'e806', + 'trash' => 'e846', + 'plus' => 'e805', + 'cancel' => 'e804', + 'help' => 'e85b', + 'angle-double-right' => 'e87b', + 'up-big' => 'e825', + 'down-big' => 'e828', + 'down-open' => 'e821', + ); + + private $mappedUtf8Icons; + + private $reversedUtf8Icons; + + private static $instance; + + public function __construct() + { + $this->prepareIconMappings(); + } + + public static function instance() + { + if (self::$instance === null) { + self::$instance = new static; + } + + return self::$instance; + } + + public function characterIconName($character) + { + if (array_key_exists($character, $this->reversedUtf8Icons)) { + return $this->reversedUtf8Icons[$character]; + } else { + throw new ProgrammingError('There is no mapping for the given character'); + } + } + + protected function hexToCharacter($hex) + { + return json_decode('"\u' . $hex . '"'); + } + + public function iconCharacter($name) + { + if (array_key_exists($name, $this->mappedUtf8Icons)) { + return $this->mappedUtf8Icons[$name]; + } else { + return $this->mappedUtf8Icons['help']; + } + } + + protected function prepareIconMappings() + { + $this->mappedUtf8Icons = array(); + $this->reversedUtf8Icons = array(); + foreach ($this->icons as $name => $hex) { + $character = $this->hexToCharacter($hex); + $this->mappedUtf8Icons[$name] = $character; + $this->reversedUtf8Icons[$character] = $name; + } + } +} diff --git a/library/Director/Web/Form/IplElement/ExtensibleSetElement.php b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php new file mode 100644 index 0000000..a4dbb20 --- /dev/null +++ b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php @@ -0,0 +1,570 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\IplElement; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\IcingaConfig\ExtensibleSet as Set; +use Icinga\Module\Director\Web\Form\IconHelper; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\Translation\TranslationHelper; + +class ExtensibleSetElement extends BaseHtmlElement +{ + use TranslationHelper; + + protected $tag = 'ul'; + + /** @var Set */ + protected $set; + + private $id; + + private $name; + + private $value; + + private $description; + + private $multiOptions; + + private $validOptions; + + private $chosenOptionCount = 0; + + private $suggestionContext; + + private $sorted = false; + + private $disabled = false; + + private $remainingAttribs; + + private $hideOptions = []; + + private $inherited; + + private $inheritedFrom; + + protected $defaultAttributes = [ + 'class' => 'extensible-set' + ]; + + protected function __construct($name) + { + $this->name = $this->id = $name; + } + + public function hideOptions($options) + { + $this->hideOptions = array_merge($this->hideOptions, $options); + return $this; + } + + private function setMultiOptions($options) + { + $this->multiOptions = $options; + $this->validOptions = $this->flattenOptions($options); + } + + protected function isValidOption($option) + { + if ($this->validOptions === null) { + if ($this->suggestionContext === null) { + return true; + } else { + // TODO: ask suggestionContext, if any + return true; + } + } else { + return in_array($option, $this->validOptions); + } + } + + private function disable($disable = true) + { + $this->disabled = (bool) $disable; + } + + private function isDisabled() + { + return $this->disabled; + } + + private function isSorted() + { + return $this->sorted; + } + + public function setValue($value) + { + if ($value instanceof Set) { + $value = $value->toPlainObject(); + } + + if (is_array($value)) { + $value = array_filter($value, 'strlen'); + } + + if (null !== $value && ! is_array($value)) { + throw new ProgrammingError( + 'Got unexpected value, no array: %s', + var_export($value, 1) + ); + } + + $this->value = $value; + return $this; + } + + protected function extractZfInfo(&$attribs = null) + { + if ($attribs === null) { + return; + } + + foreach (['id', 'name', 'descriptions'] as $key) { + if (array_key_exists($key, $attribs)) { + $this->$key = $attribs[$key]; + unset($attribs[$key]); + } + } + if (array_key_exists('disable', $attribs)) { + $this->disable($attribs['disable']); + unset($attribs['disable']); + } + if (array_key_exists('value', $attribs)) { + $this->setValue($attribs['value']); + unset($attribs['value']); + } + if (array_key_exists('inherited', $attribs)) { + $this->inherited = $attribs['inherited']; + unset($attribs['inherited']); + } + if (array_key_exists('inheritedFrom', $attribs)) { + $this->inheritedFrom = $attribs['inheritedFrom']; + unset($attribs['inheritedFrom']); + } + + if (array_key_exists('multiOptions', $attribs)) { + $this->setMultiOptions($attribs['multiOptions']); + unset($attribs['multiOptions']); + } + + if (array_key_exists('hideOptions', $attribs)) { + $this->hideOptions($attribs['hideOptions']); + unset($attribs['hideOptions']); + } + + if (array_key_exists('sorted', $attribs)) { + $this->sorted = (bool) $attribs['sorted']; + unset($attribs['sorted']); + } + + if (array_key_exists('description', $attribs)) { + $this->description = $attribs['description']; + unset($attribs['description']); + } + + if (array_key_exists('suggest', $attribs)) { + $this->suggestionContext = $attribs['suggest']; + unset($attribs['suggest']); + } + + if (! empty($attribs)) { + $this->remainingAttribs = $attribs; + } + } + + /** + * Generates an 'extensible set' element. + * + * @codingStandardsIgnoreEnd + * + * @param string|array $name If a string, the element name. If an + * array, all other parameters are ignored, and the array elements + * are used in place of added parameters. + * + * @param mixed $value The element value. + * + * @param array $attribs Attributes for the element tag. + * + * @return string The element XHTML. + */ + public static function fromZfDingens($name, $value = null, $attribs = null) + { + $el = new static($name); + $el->extractZfInfo($attribs); + $el->setValue($value); + return $el->render(); + } + + protected function assemble() + { + $this->addChosenOptions(); + $this->addAddMore(); + + if ($this->isSorted()) { + $this->getAttributes()->add('class', 'sortable'); + } + if (null !== $this->description) { + $this->addDescription($this->description); + } + } + + private function eventuallyAddAutosuggestion(BaseHtmlElement $element) + { + if ($this->suggestionContext !== null) { + $attrs = $element->getAttributes(); + $attrs->add('class', 'director-suggest'); + $attrs->set([ + 'data-suggestion-context' => $this->suggestionContext, + ]); + } + + return $element; + } + + private function hasAvailableMultiOptions() + { + return count($this->multiOptions) > 1 || strlen(key($this->multiOptions)); + } + + private function addAddMore() + { + $cnt = $this->chosenOptionCount; + + if ($this->multiOptions) { + if (! $this->hasAvailableMultiOptions()) { + return; + } + $field = Html::tag('select', ['class' => 'autosubmit']); + $more = $this->inherited === null + ? $this->translate('- add more -') + : $this->getInheritedInfo(); + $field->add(Html::tag('option', [ + 'value' => '', + 'tabindex' => '-1' + ], $more)); + + foreach ($this->multiOptions as $key => $label) { + if ($key === null) { + $key = ''; + } + if (is_array($label)) { + $optGroup = Html::tag('optgroup', ['label' => $key]); + foreach ($label as $grpKey => $grpLabel) { + $optGroup->add( + Html::tag('option', ['value' => $grpKey], $grpLabel) + ); + } + $field->add($optGroup); + } else { + $option = Html::tag('option', ['value' => $key], $label); + $field->add($option); + } + } + } else { + $field = Html::tag('input', [ + 'type' => 'text', + 'placeholder' => $this->inherited === null + ? $this->translate('Add a new one...') + : $this->getInheritedInfo(), + ]); + } + $field->addAttributes([ + 'id' => $this->id . $this->suffix($cnt), + 'name' => $this->name . '[]', + ]); + $this->eventuallyAddAutosuggestion( + $this->addRemainingAttributes( + $this->eventuallyDisable($field) + ) + ); + if ($cnt !== 0) { // TODO: was === 0?! + $field->getAttributes()->add('class', 'extend-set'); + } + + if ($this->suggestionContext === null) { + $this->add(Html::tag('li', null, [ + $this->createAddNewButton(), + $field + ])); + } else { + $this->add(Html::tag('li', null, [ + $this->newInlineButtons( + $this->renderDropDownButton() + ), + $field + ])); + } + } + + private function getInheritedInfo() + { + if ($this->inheritedFrom === null) { + return \sprintf( + $this->translate('%s (inherited)'), + $this->stringifyInheritedValue() + ); + } else { + return \sprintf( + $this->translate('%s (inherited from %s)'), + $this->stringifyInheritedValue(), + $this->inheritedFrom + ); + } + } + + private function stringifyInheritedValue() + { + if (\is_array($this->inherited)) { + return \implode(', ', $this->inherited); + } else { + return \sprintf( + $this->translate('%s (not an Array!)'), + \var_export($this->inherited, 1) + ); + } + } + + private function createAddNewButton() + { + return $this->newInlineButtons( + $this->eventuallyDisable($this->renderAddButton()) + ); + } + + private function addChosenOptions() + { + if (null === $this->value) { + return; + } + $total = count($this->value); + + foreach ($this->value as $val) { + if (in_array($val, $this->hideOptions)) { + continue; + } + + if ($this->multiOptions !== null) { + if ($this->isValidOption($val)) { + $this->multiOptions = $this->removeOption( + $this->multiOptions, + $val + ); + // TODO: + // $this->removeOption($val); + } + } + + $text = Html::tag('input', [ + 'type' => 'text', + 'name' => $this->name . '[]', + 'id' => $this->id . $this->suffix($this->chosenOptionCount), + 'value' => $val + ]); + $text->getAttributes()->set([ + 'autocomplete' => 'off', + 'autocorrect' => 'off', + 'autocapitalize' => 'off', + 'spellcheck' => 'false', + ]); + + $this->addRemainingAttributes($this->eventuallyDisable($text)); + $this->add(Html::tag('li', null, [ + $this->getOptionButtons($this->chosenOptionCount, $total), + $text + ])); + $this->chosenOptionCount++; + } + } + + private function addRemainingAttributes(BaseHtmlElement $element) + { + if ($this->remainingAttribs !== null) { + $element->getAttributes()->add($this->remainingAttribs); + } + + return $element; + } + + private function eventuallyDisable(BaseHtmlElement $element) + { + if ($this->isDisabled()) { + $this->disableElement($element); + } + + return $element; + } + + private function disableElement(BaseHtmlElement $element) + { + $element->getAttributes()->set('disabled', 'disabled'); + return $element; + } + + private function disableIf(BaseHtmlElement $element, $condition) + { + if ($condition) { + $this->disableElement($element); + } + + return $element; + } + + private function getOptionButtons($cnt, $total) + { + if ($this->isDisabled()) { + return []; + } + $first = $cnt === 0; + $last = $cnt === $total - 1; + $name = $this->name; + $buttons = $this->newInlineButtons(); + if ($this->isSorted()) { + $buttons->add([ + $this->disableIf($this->renderDownButton($name, $cnt), $last), + $this->disableIf($this->renderUpButton($name, $cnt), $first) + ]); + } + + $buttons->add($this->renderDeleteButton($name, $cnt)); + + return $buttons; + } + + protected function newInlineButtons($content = null) + { + return Html::tag('span', ['class' => 'inline-buttons'], $content); + } + + protected function addDescription($description) + { + $this->add( + Html::tag('p', ['class' => 'description'], $description) + ); + } + + private function flattenOptions($options) + { + $flat = array(); + + foreach ($options as $key => $option) { + if (is_array($option)) { + foreach ($option as $k => $o) { + $flat[] = $k; + } + } else { + $flat[] = $key; + } + } + + return $flat; + } + + private function removeOption($options, $option) + { + $unset = array(); + foreach ($options as $key => & $value) { + if (is_array($value)) { + $value = $this->removeOption($value, $option); + if (empty($value)) { + $unset[] = $key; + } + } elseif ($key === $option) { + $unset[] = $key; + } + } + + foreach ($unset as $key) { + unset($options[$key]); + } + + return $options; + } + + private function suffix($cnt) + { + if ($cnt === 0) { + return ''; + } else { + return '_' . $cnt; + } + } + + private function renderDropDownButton() + { + return $this->createRelatedAction( + 'drop-down', + $this->name, + $this->translate('Show available options'), + 'down-open' + ); + } + + private function renderAddButton() + { + return $this->createRelatedAction( + 'add', + // This would interfere with how PHP resolves _POST arrays. So we + // use a fake name for now, that way the button will be ignored and + // behave similar to an auto-submission + 'X_' . $this->name, + $this->translate('Add a new entry'), + 'plus' + ); + } + + private function renderDeleteButton($name, $cnt) + { + return $this->createRelatedAction( + 'remove', + $name . '_' . $cnt, + $this->translate('Remove this entry'), + 'cancel' + ); + } + + private function renderUpButton($name, $cnt) + { + return $this->createRelatedAction( + 'move-up', + $name . '_' . $cnt, + $this->translate('Move up'), + 'up-big' + ); + } + + private function renderDownButton($name, $cnt) + { + return $this->createRelatedAction( + 'move-down', + $name . '_' . $cnt, + $this->translate('Move down'), + 'down-big' + ); + } + + protected function makeActionName($name, $action) + { + return $name . '__' . str_replace('-', '_', strtoupper($action)); + } + + protected function createRelatedAction( + $action, + $name, + $title, + $icon + ) { + $input = Html::tag('input', [ + 'type' => 'submit', + 'class' => ['related-action', 'action-' . $action], + 'name' => $this->makeActionName($name, $action), + 'value' => IconHelper::instance()->iconCharacter($icon), + 'title' => $title + ]); + + return $input; + } +} diff --git a/library/Director/Web/Form/QuickBaseForm.php b/library/Director/Web/Form/QuickBaseForm.php new file mode 100644 index 0000000..8d25ffb --- /dev/null +++ b/library/Director/Web/Form/QuickBaseForm.php @@ -0,0 +1,177 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use ipl\Html\Html; +use ipl\Html\ValidHtml; +use Zend_Form; + +abstract class QuickBaseForm extends Zend_Form implements ValidHtml +{ + /** + * The Icinga module this form belongs to. Usually only set if the + * form is initialized through the FormLoader + * + * @var Module + */ + protected $icingaModule; + + protected $icingaModuleName; + + private $hintCount = 0; + + public function __construct($options = null) + { + $this->callZfConstructor($this->handleOptions($options)) + ->initializePrefixPaths(); + } + + protected function callZfConstructor($options = null) + { + parent::__construct($options); + return $this; + } + + protected function initializePrefixPaths() + { + $this->addPrefixPathsForDirector(); + if ($this->icingaModule && $this->icingaModuleName !== 'director') { + $this->addPrefixPathsForModule($this->icingaModule); + } + } + + protected function addPrefixPathsForDirector() + { + $module = Icinga::app() + ->getModuleManager() + ->loadModule('director') + ->getModule('director'); + + $this->addPrefixPathsForModule($module); + } + + public function addPrefixPathsForModule(Module $module) + { + $basedir = sprintf( + '%s/%s/Web/Form', + $module->getLibDir(), + ucfirst($module->getName()) + ); + + $this->addPrefixPath( + __NAMESPACE__ . '\\Element\\', + $basedir . '/Element', + static::ELEMENT + ); + + return $this; + } + + public function addHidden($name, $value = null) + { + $this->addElement('hidden', $name); + $el = $this->getElement($name); + $el->setDecorators(array('ViewHelper')); + if ($value !== null) { + $this->setDefault($name, $value); + $el->setValue($value); + } + + return $this; + } + + // TODO: Should be an element + public function addHtmlHint($html, $options = []) + { + return $this->addHtml( + Html::tag('div', ['class' => 'hint'], $html), + $options + ); + } + + public function addHtml($html, $options = []) + { + if ($html instanceof ValidHtml) { + $html = $html->render(); + } + + if (array_key_exists('name', $options)) { + $name = $options['name']; + unset($options['name']); + } else { + $name = '_HINT' . ++$this->hintCount; + } + + $this->addElement('simpleNote', $name, $options); + $this->getElement($name) + ->setValue($html) + ->setIgnore(true) + ->setDecorators(array('ViewHelper')); + + return $this; + } + + public function optionalEnum($enum, $nullLabel = null) + { + if ($nullLabel === null) { + $nullLabel = $this->translate('- please choose -'); + } + + return array(null => $nullLabel) + $enum; + } + + protected function handleOptions($options = null) + { + if ($options === null) { + return $options; + } + + if (array_key_exists('icingaModule', $options)) { + /** @var Module icingaModule */ + $this->icingaModule = $options['icingaModule']; + $this->icingaModuleName = $this->icingaModule->getName(); + unset($options['icingaModule']); + } + + return $options; + } + + public function setIcingaModule(Module $module) + { + $this->icingaModule = $module; + return $this; + } + + protected function loadForm($name, Module $module = null) + { + if ($module === null) { + $module = $this->icingaModule; + } + + return FormLoader::load($name, $module); + } + + protected function valueIsEmpty($value) + { + if ($value === null) { + return true; + } + + if (is_array($value)) { + return empty($value); + } + + return strlen($value) === 0; + } + + public function translate($string) + { + if ($this->icingaModuleName === null) { + return t($string); + } else { + return mt($this->icingaModuleName, $string); + } + } +} diff --git a/library/Director/Web/Form/QuickForm.php b/library/Director/Web/Form/QuickForm.php new file mode 100644 index 0000000..91c8f00 --- /dev/null +++ b/library/Director/Web/Form/QuickForm.php @@ -0,0 +1,641 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Web\Notification; +use Icinga\Web\Request; +use Icinga\Web\Response; +use Icinga\Web\Url; +use InvalidArgumentException; +use Exception; +use RuntimeException; + +/** + * QuickForm wants to be a base class for simple forms + */ +abstract class QuickForm extends QuickBaseForm +{ + const ID = '__FORM_NAME'; + + const CSRF = '__FORM_CSRF'; + + /** + * The name of this form + */ + protected $formName; + + /** + * Whether the form has been sent + */ + protected $hasBeenSent; + + /** + * Whether the form has been sent + */ + protected $hasBeenSubmitted; + + /** + * The submit caption, element - still tbd + */ + // protected $submit; + + /** + * Our request + */ + protected $request; + + protected $successUrl; + + protected $successMessage; + + protected $submitLabel; + + protected $submitButtonName; + + protected $deleteButtonName; + + protected $fakeSubmitButtonName; + + /** + * Whether form elements have already been created + */ + protected $didSetup = false; + + protected $isApiRequest = false; + + protected $successCallbacks = []; + + protected $calledSuccessCallbacks = false; + + protected $onRequestCallbacks = []; + + protected $calledOnRequestCallbacks = false; + + public function __construct($options = null) + { + parent::__construct($options); + + $this->setMethod('post'); + $this->getActionFromRequest() + ->createIdElement() + ->regenerateCsrfToken() + ->setPreferredDecorators(); + } + + protected function getActionFromRequest() + { + $this->setAction(Url::fromRequest()); + return $this; + } + + protected function setPreferredDecorators() + { + $current = $this->getAttrib('class'); + $current .= ' director-form'; + if ($current) { + $this->setAttrib('class', "$current autofocus"); + } else { + $this->setAttrib('class', 'autofocus'); + } + $this->setDecorators( + array( + 'Description', + array('FormErrors', array('onlyCustomFormErrors' => true)), + 'FormElements', + 'Form' + ) + ); + + return $this; + } + + protected function addSubmitButton($label, $options = []) + { + $el = $this->createElement('submit', $label, $options) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->submitButtonName = $el->getName(); + $this->setSubmitLabel($label); + $this->addElement($el); + } + + protected function addStandaloneSubmitButton($label, $options = []) + { + $this->addSubmitButton($label, $options); + $this->addDisplayGroup([$this->submitButtonName], 'buttons', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'p')), + ), + 'order' => 1000, + )); + } + + protected function addSubmitButtonIfSet() + { + if (false === ($label = $this->getSubmitLabel())) { + return; + } + + if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) { + return; + } + + $this->addSubmitButton($label); + + $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT', array( + 'role' => 'none', + 'tabindex' => '-1', + )) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->fakeSubmitButtonName = $fakeEl->getName(); + $this->addElement($fakeEl); + + $this->addDisplayGroup( + array($this->fakeSubmitButtonName), + 'fake_button', + array( + 'decorators' => array('FormElements'), + 'order' => 1, + ) + ); + + $this->addButtonDisplayGroup(); + } + + protected function addButtonDisplayGroup() + { + $grp = array( + $this->submitButtonName, + $this->deleteButtonName + ); + $this->addDisplayGroup($grp, 'buttons', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'DtDdWrapper', + ), + 'order' => 1000, + )); + } + + protected function addSimpleDisplayGroup($elements, $name, $options) + { + if (! array_key_exists('decorators', $options)) { + $options['decorators'] = array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ); + } + + return $this->addDisplayGroup($elements, $name, $options); + } + + protected function createIdElement() + { + if ($this->isApiRequest()) { + return $this; + } + $this->detectName(); + $this->addHidden(self::ID, $this->getName()); + $this->getElement(self::ID)->setIgnore(true); + return $this; + } + + public function getSentValue($name, $default = null) + { + $request = $this->getRequest(); + if ($request->isPost() && $this->hasBeenSent()) { + return $request->getPost($name); + } else { + return $default; + } + } + + public function getSubmitLabel() + { + if ($this->submitLabel === null) { + return $this->translate('Submit'); + } + + return $this->submitLabel; + } + + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + return $this; + } + + public function setApiRequest($isApiRequest = true) + { + $this->isApiRequest = $isApiRequest; + return $this; + } + + public function isApiRequest() + { + if ($this->isApiRequest === null) { + if ($this->request === null) { + throw new RuntimeException( + 'Early access to isApiRequest(). This is not possible, sorry' + ); + } + + return $this->getRequest()->isApiRequest(); + } else { + return $this->isApiRequest; + } + } + + public function regenerateCsrfToken() + { + if ($this->isApiRequest()) { + return $this; + } + if (! $element = $this->getElement(self::CSRF)) { + $this->addHidden(self::CSRF, CsrfToken::generate()); + $element = $this->getElement(self::CSRF); + } + $element->setIgnore(true); + + return $this; + } + + public function removeCsrfToken() + { + $this->removeElement(self::CSRF); + return $this; + } + + public function setSuccessUrl($url, $params = null) + { + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + if ($params !== null) { + $url->setParams($params); + } + $this->successUrl = $url; + return $this; + } + + public function getSuccessUrl() + { + $url = $this->successUrl ?: $this->getAction(); + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + + return $url; + } + + protected function beforeSetup() + { + } + + public function setup() + { + } + + protected function onSetup() + { + } + + public function setAction($action) + { + if ($action instanceof Url) { + $action = $action->getAbsoluteUrl('&'); + } + + return parent::setAction($action); + } + + public function hasBeenSubmitted() + { + if ($this->hasBeenSubmitted === null) { + $req = $this->getRequest(); + if ($req->isApiRequest()) { + return $this->hasBeenSubmitted = true; + } + if ($req->isPost()) { + if (! $this->hasSubmitButton()) { + return $this->hasBeenSubmitted = $this->hasBeenSent(); + } + + $this->hasBeenSubmitted = $this->pressedButton( + $this->fakeSubmitButtonName, + $this->getSubmitLabel() + ) || $this->pressedButton( + $this->submitButtonName, + $this->getSubmitLabel() + ); + } else { + $this->hasBeenSubmitted = false; + } + } + + return $this->hasBeenSubmitted; + } + + protected function hasSubmitButton() + { + return $this->submitButtonName !== null; + } + + protected function pressedButton($name, $label) + { + $req = $this->getRequest(); + if (! $req->isPost()) { + return false; + } + + $req = $this->getRequest(); + $post = $req->getPost(); + + return array_key_exists($name, $post) + && $post[$name] === $label; + } + + protected function beforeValidation($data = array()) + { + } + + public function prepareElements() + { + if (! $this->didSetup) { + $this->beforeSetup(); + $this->setup(); + $this->onSetup(); + $this->didSetup = true; + } + + return $this; + } + + public function handleRequest(Request $request = null) + { + if ($request === null) { + $request = $this->getRequest(); + } else { + $this->setRequest($request); + } + + $this->prepareElements(); + $this->addSubmitButtonIfSet(); + + if ($this->hasBeenSent()) { + $post = $request->getPost(); + if ($this->hasBeenSubmitted()) { + $this->beforeValidation($post); + if ($this->isValid($post)) { + try { + $this->onSuccess(); + $this->callOnSuccessCallables(); + } catch (Exception $e) { + $this->addException($e); + $this->onFailure(); + } + } else { + $this->onFailure(); + } + } else { + $this->setDefaults($post); + } + } + + return $this; + } + + public function addException(Exception $e, $elementName = null) + { + $msg = $this->getErrorMessageForException($e); + if ($el = $this->getElement($elementName)) { + $el->addError($msg); + } else { + $this->addError($msg); + } + } + + public function addUniqueErrorMessage($msg) + { + if (! in_array($msg, $this->getErrorMessages())) { + $this->addErrorMessage($msg); + } + + return $this; + } + + public function addUniqueException(Exception $e) + { + $msg = $this->getErrorMessageForException($e); + + if (! in_array($msg, $this->getErrorMessages())) { + $this->addErrorMessage($msg); + } + + return $this; + } + + protected function getErrorMessageForException(Exception $e) + { + $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY); + $file = array_pop($file); + return sprintf( + '%s (%s:%d)', + $e->getMessage(), + $file, + $e->getLine() + ); + } + + public function onSuccess() + { + $this->redirectOnSuccess(); + } + + /** + * @param callable $callable + * @return $this + */ + public function callOnRequest($callable) + { + if (! is_callable($callable)) { + throw new InvalidArgumentException( + 'callOnRequest() expects a callable' + ); + } + $this->onRequestCallbacks[] = $callable; + + return $this; + } + + protected function callOnRequestCallables() + { + if (! $this->calledOnRequestCallbacks) { + $this->calledOnRequestCallbacks = true; + foreach ($this->onRequestCallbacks as $callable) { + $callable($this); + } + } + } + + /** + * @param callable $callable + * @return $this + */ + public function callOnSuccess($callable) + { + if (! is_callable($callable)) { + throw new InvalidArgumentException( + 'callOnSuccess() expects a callable' + ); + } + $this->successCallbacks[] = $callable; + + return $this; + } + + protected function callOnSuccessCallables() + { + if (! $this->calledSuccessCallbacks) { + $this->calledSuccessCallbacks = true; + foreach ($this->successCallbacks as $callable) { + $callable($this); + } + } + } + + public function setSuccessMessage($message) + { + $this->successMessage = $message; + return $this; + } + + public function getSuccessMessage($message = null) + { + if ($message !== null) { + return $message; + } + if ($this->successMessage === null) { + return t('Form has successfully been sent'); + } + return $this->successMessage; + } + + public function redirectOnSuccess($message = null) + { + if ($this->isApiRequest()) { + // TODO: Set the status line message? + $this->successMessage = $this->getSuccessMessage($message); + $this->callOnSuccessCallables(); + return; + } + + $url = $this->getSuccessUrl(); + $this->callOnSuccessCallables(); + $this->notifySuccess($this->getSuccessMessage($message)); + $this->redirectAndExit($url); + } + + public function onFailure() + { + } + + public function notifySuccess($message = null) + { + if ($message === null) { + $message = t('Form has successfully been sent'); + } + Notification::success($message); + return $this; + } + + public function notifyError($message) + { + Notification::error($message); + return $this; + } + + protected function redirectAndExit($url) + { + /** @var Response $response */ + $response = Icinga::app()->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function setHttpResponseCode($code) + { + Icinga::app()->getFrontController()->getResponse()->setHttpResponseCode($code); + return $this; + } + + protected function onRequest() + { + $this->callOnRequestCallables(); + } + + public function setRequest(Request $request) + { + if ($this->request !== null) { + throw new RuntimeException('Unable to set request twice'); + } + + $this->request = $request; + $this->prepareElements(); + $this->onRequest(); + $this->callOnRequestCallables(); + + return $this; + } + + /** + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + /** @var Request $request */ + $request = Icinga::app()->getFrontController()->getRequest(); + $this->setRequest($request); + } + return $this->request; + } + + public function hasBeenSent() + { + if ($this->hasBeenSent === null) { + + /** @var Request $req */ + if ($this->request === null) { + $req = Icinga::app()->getFrontController()->getRequest(); + } else { + $req = $this->request; + } + + if ($req->isApiRequest()) { + $this->hasBeenSent = true; + } elseif ($req->isPost()) { + $post = $req->getPost(); + $this->hasBeenSent = array_key_exists(self::ID, $post) && + $post[self::ID] === $this->getName(); + } else { + $this->hasBeenSent = false; + } + } + + return $this->hasBeenSent; + } + + protected function detectName() + { + if ($this->formName !== null) { + $this->setName($this->formName); + } else { + $this->setName(get_class($this)); + } + } +} diff --git a/library/Director/Web/Form/QuickSubForm.php b/library/Director/Web/Form/QuickSubForm.php new file mode 100644 index 0000000..2487d35 --- /dev/null +++ b/library/Director/Web/Form/QuickSubForm.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +abstract class QuickSubForm extends QuickBaseForm +{ + /** + * Whether or not form elements are members of an array + * @codingStandardsIgnoreStart + * @var bool + */ + protected $_isArray = true; + // @codingStandardsIgnoreEnd + + /** + * Load the default decorators + * + * @return $this + */ + public function loadDefaultDecorators() + { + if ($this->loadDefaultDecoratorsIsDisabled()) { + return $this; + } + + $decorators = $this->getDecorators(); + if (empty($decorators)) { + $this->addDecorator('FormElements') + ->addDecorator('HtmlTag', array('tag' => 'dl')) + ->addDecorator('Fieldset') + ->addDecorator('DtDdWrapper'); + } + + return $this; + } +} diff --git a/library/Director/Web/Form/Validate/IsDataListEntry.php b/library/Director/Web/Form/Validate/IsDataListEntry.php new file mode 100644 index 0000000..5762d2e --- /dev/null +++ b/library/Director/Web/Form/Validate/IsDataListEntry.php @@ -0,0 +1,55 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Validate; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatalistEntry; +use Zend_Validate_Abstract; + +class IsDataListEntry extends Zend_Validate_Abstract +{ + const INVALID = 'intInvalid'; + + /** @var Db */ + private $db; + + /** @var int */ + private $dataListId; + + public function __construct($dataListId, Db $db) + { + $this->db = $db; + $this->dataListId = (int) $dataListId; + } + + public function isValid($value) + { + if (is_array($value)) { + foreach ($value as $name) { + if (! $this->isListEntry($name)) { + $this->_error(self::INVALID, $value); + + return false; + } + } + + return true; + } + + if ($this->isListEntry($value)) { + return true; + } else { + $this->_error(self::INVALID, $value); + + return false; + } + } + + protected function isListEntry($name) + { + return DirectorDatalistEntry::exists([ + 'list_id' => $this->dataListId, + 'entry_name' => $name, + ], $this->db); + } +} diff --git a/library/Director/Web/Form/Validate/NamePattern.php b/library/Director/Web/Form/Validate/NamePattern.php new file mode 100644 index 0000000..fac44d9 --- /dev/null +++ b/library/Director/Web/Form/Validate/NamePattern.php @@ -0,0 +1,38 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Validate; + +use Icinga\Module\Director\Restriction\MatchingFilter; +use Zend_Validate_Abstract; + +class NamePattern extends Zend_Validate_Abstract +{ + const INVALID = 'intInvalid'; + + private $filter; + + public function __construct($pattern) + { + if (! is_array($pattern)) { + $pattern = [$pattern]; + } + + $this->filter = MatchingFilter::forPatterns($pattern, 'value'); + + $this->_messageTemplates[self::INVALID] = sprintf( + 'Does not match %s', + (string) $this->filter + ); + } + + public function isValid($value) + { + if ($this->filter->matches((object) ['value' => $value])) { + return true; + } else { + $this->_error(self::INVALID, $value); + + return false; + } + } +} diff --git a/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php new file mode 100644 index 0000000..1aabada --- /dev/null +++ b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php @@ -0,0 +1,196 @@ +<?php + +namespace Icinga\Module\Director\Web\Navigation\Renderer; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Application\Web; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchStore; +use Icinga\Module\Director\Db\Migrations; +use Icinga\Module\Director\KickstartHelper; +use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; +use Icinga\Module\Director\Web\Window; + +class ConfigHealthItemRenderer extends BadgeNavigationItemRenderer +{ + use DirectorDb; + + private $directorState = self::STATE_OK; + + private $message; + + private $count = 0; + + private $window; + + protected function hasProblems() + { + try { + $this->checkHealth(); + } catch (Exception $e) { + $this->directorState = self::STATE_UNKNOWN; + $this->count = 1; + $this->message = $e->getMessage(); + } + + return $this->count > 0; + } + + public function getState() + { + return $this->directorState; + } + + public function getCount() + { + if ($this->hasProblems()) { + return $this->count; + } else { + return 0; + } + } + + public function getTitle() + { + return $this->message; + } + + protected function checkHealth() + { + $db = $this->db(); + if (! $db) { + $this->directorState = self::STATE_PENDING; + $this->count = 1; + $this->message = $this->translate( + 'No database has been configured for Icinga Director' + ); + + return; + } + + $migrations = new Migrations($db); + if (!$migrations->hasSchema()) { + $this->count = 1; + $this->directorState = self::STATE_CRITICAL; + $this->message = $this->translate( + 'Director database schema has not been created yet' + ); + return; + } + + if ($migrations->hasPendingMigrations()) { + $this->count = $migrations->countPendingMigrations(); + $this->directorState = self::STATE_PENDING; + $this->message = sprintf( + $this->translate('There are %d pending database migrations'), + $this->count + ); + return; + } + + $kickstart = new KickstartHelper($db); + if ($kickstart->isRequired()) { + $this->directorState = self::STATE_PENDING; + $this->count = 1; + $this->message = $this->translate( + 'No API user configured, you might run the kickstart helper' + ); + + return; + } + + $branch = Branch::detect(new BranchStore($this->db())); + if ($branch->isBranch()) { + $count = $branch->getActivityCount(); + if ($count > 0) { + $this->directorState = self::STATE_PENDING; + $this->count = $count; + $this->message = sprintf( + $this->translate('%s config changes are available in your configuration branch'), + $count + ); + } + + return; + } + + $pendingChanges = $db->countActivitiesSinceLastDeployedConfig(); + + if ($pendingChanges > 0) { + $this->directorState = self::STATE_WARNING; + $this->count = $pendingChanges; + $this->message = sprintf( + $this->translate( + '%s config changes happend since the last deployed configuration' + ), + $pendingChanges + ); + } + } + + protected function translate($message) + { + return mt('director', $message); + } + + protected function db() + { + try { + $resourceName = Config::module('director')->get('db', 'resource'); + if ($resourceName) { + // Window might have switched to another DB: + return Db::fromResourceName($this->getDbResourceName()); + } else { + return false; + } + } catch (Exception $e) { + return false; + } + } + + /** + * TODO: the following methods are for the DirectorDb trait, we need + * something better in future. It is required to show Health + * related to the DB chosen in the current Window + * + * @codingStandardsIgnoreStart + * @return Auth + */ + protected function Auth() + { + return Auth::getInstance(); + } + + /** + * @return Window + */ + public function Window() + { + if ($this->window === null) { + try { + /** @var $app Web */ + $app = Icinga::app(); + $this->window = new Window( + $app->getRequest()->getHeader('X-Icinga-WindowId') + ); + } catch (Exception $e) { + $this->window = new Window(Window::UNDEFINED); + } + } + return $this->window; + } + + /** + * @return Config + */ + protected function Config() + { + // @codingStandardsIgnoreEnd + return Config::module('director'); + } +} diff --git a/library/Director/Web/ObjectPreview.php b/library/Director/Web/ObjectPreview.php new file mode 100644 index 0000000..e7648e1 --- /dev/null +++ b/library/Director/Web/ObjectPreview.php @@ -0,0 +1,182 @@ +<?php + +namespace Icinga\Module\Director\Web; + +use gipfl\Web\Widget\Hint; +use ipl\Html\Text; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Web\Request; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\ControlsAndContent; + +class ObjectPreview +{ + use TranslationHelper; + + /** @var IcingaObject */ + protected $object; + + /** @var Request */ + protected $request; + + public function __construct(IcingaObject $object, Request $request) + { + $this->object = $object; + $this->request = $request; + } + + /** + * @param ControlsAndContent $cc + * @throws \Icinga\Exception\NotFoundError + */ + public function renderTo(ControlsAndContent $cc) + { + $object = $this->object; + $url = $this->request->getUrl(); + $params = $url->getParams(); + $cc->addTitle( + $this->translate('Config preview: %s'), + $object->getObjectName() + ); + + if ($params->shift('resolved')) { + $object = $object::fromPlainObject( + $object->toPlainObject(true), + $object->getConnection() + ); + + $cc->actions()->add(Link::create( + $this->translate('Show normal'), + $url->without('resolved'), + null, + ['class' => 'icon-resize-small state-warning'] + )); + } else { + try { + if ($object->supportsImports() && $object->imports()->count() > 0) { + $cc->actions()->add(Link::create( + $this->translate('Show resolved'), + $url->with('resolved', true), + null, + ['class' => 'icon-resize-full'] + )); + } + } catch (NestingError $e) { + // No resolve link with nesting errors + } + } + + $content = $cc->content(); + if ($object->isDisabled()) { + $content->add(Hint::error( + $this->translate('This object will not be deployed as it has been disabled') + )); + } + if ($object->isExternal()) { + $content->add(Html::tag('p', null, $this->translate(( + 'This is an external object. It has been imported from Icinga 2 through the' + . ' Core API and cannot be managed with the Icinga Director. It is however' + . ' perfectly valid to create objects using this or referring to this object.' + . ' You might also want to define related Fields to make work based on this' + . ' object more enjoyable.' + )))); + } + $config = $object->toSingleIcingaConfig(); + + foreach ($config->getFiles() as $filename => $file) { + if (! $object->isExternal()) { + $content->add(Html::tag('h2', null, $filename)); + } + + $classes = array(); + if ($object->isDisabled()) { + $classes[] = 'disabled'; + } elseif ($object->isExternal()) { + $classes[] = 'logfile'; + } + + $type = $object->getShortTableName(); + + $plain = Html::wantHtml($file->getContent())->render(); + $plain = preg_replace_callback( + '/^(\s+import\s+\"\;)(.+)(\"\;)/m', + [$this, 'linkImport'], + $plain + ); + + if ($type !== 'command') { + $plain = preg_replace_callback( + '/^(\s+(?:check_|event_)?command\s+=\s+\"\;)(.+)(\"\;)/m', + [$this, 'linkCommand'], + $plain + ); + } + + $plain = preg_replace_callback( + '/^(\s+host_name\s+=\s+\"\;)(.+)(\"\;)/m', + [$this, 'linkHost'], + $plain + ); + $text = Text::create($plain)->setEscaped(); + + $content->add(Html::tag('pre', ['class' => $classes], $text)); + } + } + + /** + * @api internal + * @param $match + * @return string + */ + public function linkImport($match) + { + $blacklist = [ + 'plugin-notification-command', + 'plugin-check-command', + ]; + if (in_array($match[2], $blacklist)) { + return $match[1] . $match[2] . $match[3]; + } + + $urlObjectType = $this->object->getShortTableName(); + if ($urlObjectType === 'service_set') { + $urlObjectType = 'service'; + } + return $match[1] . Link::create( + $match[2], + sprintf("director/$urlObjectType"), + ['name' => $match[2]] + )->render() . $match[3]; + } + + /** + * @api internal + * @param $match + * @return string + */ + public function linkCommand($match) + { + return $match[1] . Link::create( + $match[2], + sprintf('director/command'), + ['name' => $match[2]] + )->render() . $match[3]; + } + + /** + * @api internal + * @param $match + * @return string + */ + public function linkHost($match) + { + return $match[1] . Link::create( + $match[2], + sprintf('director/host'), + ['name' => $match[2]] + )->render() . $match[3]; + } +} diff --git a/library/Director/Web/SelfService.php b/library/Director/Web/SelfService.php new file mode 100644 index 0000000..33756b7 --- /dev/null +++ b/library/Director/Web/SelfService.php @@ -0,0 +1,311 @@ +<?php + +namespace Icinga\Module\Director\Web; + +use Exception; +use gipfl\Web\Widget\Hint; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Core\CoreApi; +use Icinga\Module\Director\Forms\IcingaForgetApiKeyForm; +use Icinga\Module\Director\Forms\IcingaGenerateApiKeyForm; +use Icinga\Application\Icinga; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\IcingaConfig\AgentWizard; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Widget\Documentation; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\ActionBar; +use gipfl\IcingaWeb2\Widget\ControlsAndContent; + +class SelfService +{ + use TranslationHelper; + + /** @var IcingaHost */ + protected $host; + + /** @var CoreApi */ + protected $api; + + public function __construct(IcingaHost $host, CoreApi $api) + { + $this->host = $host; + $this->api = $api; + } + + /** + * @param ControlsAndContent $controller + */ + public function renderTo(ControlsAndContent $controller) + { + $host = $this->host; + if ($host->isTemplate()) { + $this->showSelfServiceTemplateInstructions($controller); + } elseif ($key = $host->getProperty('api_key')) { + $this->showRegisteredAgentInstructions($key, $controller); + } elseif ($key = $host->getSingleResolvedProperty('api_key')) { + $this->showNewAgentInstructions($controller); + } else { + $this->showLegacyAgentInstructions($controller); + } + } + + /** + * @param string $key + * @param ControlsAndContent $c + */ + protected function showRegisteredAgentInstructions($key, ControlsAndContent $c) + { + $c->addTitle($this->translate('Registered Agent')); + $c->content()->add([ + Html::tag('p', null, $this->translate( + 'This host has been registered via the Icinga Director Self Service' + . " API. In case you re-installed the host or somehow lost it's" + . ' secret key, you might want to dismiss the current key. This' + . ' would allow you to register the same host again.' + )), + Html::tag('p', null, [$this->translate('Api Key:'), ' ', Html::tag('strong', null, $key)]), + Hint::warning($this->translate( + 'It is not a good idea to do so as long as your Agent still has' + . ' a valid Self Service API key!' + )), + IcingaForgetApiKeyForm::load()->setHost($this->host)->handleRequest() + ]); + } + + /** + * @param ControlsAndContent $cc + */ + protected function showSelfServiceTemplateInstructions(ControlsAndContent $cc) + { + $host = $this->host; + $key = $host->getProperty('api_key'); + $hasKey = $key !== null; + if ($hasKey) { + $cc->addTitle($this->translate('Shared for Self Service API')); + } else { + $cc->addTitle($this->translate('Share this Template for Self Service API')); + } + + $c = $cc->content(); + /** @var ActionBar $actions */ + $actions = $cc->actions(); + $actions->setBaseTarget('_next')->add(Link::create( + $this->translate('Settings'), + 'director/settings/self-service', + null, + [ + 'title' => $this->translate('Global Self Service Setting'), + 'class' => 'icon-services', + ] + )); + + $actions->add($this->getDocumentationLink()); + + if ($hasKey) { + $c->add([ + Html::tag('p', [ + $this->translate('Api Key:'), ' ', Html::tag('strong', null, $key) + ]), + $this->getWindowsInstructions($host, $key), + Html::tag('h2', null, $this->translate('Generate a new key')), + Hint::warning($this->translate( + 'This will invalidate the former key' + )), + ]); + } + + $c->add([ + // Html::tag('p', null, $this->translate('..')), + IcingaGenerateApiKeyForm::load()->setHost($host)->handleRequest() + ]); + if ($hasKey) { + $c->add([ + Html::tag('h2', null, $this->translate('Stop sharing this Template')), + Html::tag('p', null, $this->translate( + 'You can stop sharing a Template at any time. This will' + . ' immediately invalidate the former key.' + ) . ' ' . $this->translate( + 'Generated Host keys will continue to work, but you\'ll no' + . ' longer be able to register new Hosts with this key' + )), + IcingaForgetApiKeyForm::load()->setHost($host)->handleRequest() + ]); + } + } + + protected function getWindowsInstructions($host, $key) + { + $wizard = new AgentWizard($host); + + return [ + Html::tag('h2', $this->translate('Icinga for Windows')), + Html::tag('p', Html::sprintf( + $this->translate('In case you\'re using %s, please run this Script:'), + Html::tag('a', [ + 'href' => 'https://icinga.com/docs/windows/latest/', + 'target' => '_blank', + ], $this->translate('Icinga for Windows')) + )), + Html::tag( + 'pre', + ['class' => 'logfile'], + $wizard->renderIcinga4WindowsWizardCommand($key) + ), + Html::tag('h3', $this->translate('Icinga 2 Powershell Module')), + Html::tag('p', Html::sprintf( + $this->translate('In case you\'re using the legacy %s, please run:'), + Html::tag('a', [ + 'href' => 'https://github.com/Icinga/icinga2-powershell-module', + 'target' => '_blank', + ], $this->translate('Icinga 2 Powershell Module')) + )), + Html::tag( + 'pre', + ['class' => 'logfile'], + $wizard->renderPowershellModuleInstaller($key) + ), + ]; + } + + protected function getDocumentationLink() + { + return Documentation::link( + $this->translate('Documentation'), + 'director', + '74-Self-Service-API', + $this->translate('Self Service API') + ); + } + + /** + * @param ControlsAndContent $cc + */ + protected function showNewAgentInstructions(ControlsAndContent $cc) + { + $content = $cc->content(); + $host = $this->host; + $key = $host->getSingleResolvedProperty('api_key'); + $cc->addTitle($this->translate('Configure this Agent via Self Service API')); + $cc->actions()->add($this->getDocumentationLink()); + $content->add(Html::tag('p', [ + $this->translate('Inherited Template Api Key:'), ' ', Html::tag('strong', null, $key) + ])); + $content->add($this->getWindowsInstructions($host, $key)); + } + + /** + * @param ControlsAndContent $cc + */ + protected function showLegacyAgentInstructions(ControlsAndContent $cc) + { + $host = $this->host; + $c = $cc->content(); + $docBaseUrl = 'https://docs.icinga.com/icinga2/latest/doc/module/icinga2/chapter/distributed-monitoring'; + $sectionSetup = 'distributed-monitoring-setup-satellite-client'; + $sectionTopDown = 'distributed-monitoring-top-down'; + $c->add(Html::tag('p')->add(Html::sprintf( + 'Please check the %s for more related information.' + . ' The Director-assisted setup corresponds to configuring a %s environment.', + Html::tag( + 'a', + ['href' => $docBaseUrl . '#' . $sectionSetup], + $this->translate('Icinga 2 Client documentation') + ), + Html::tag( + 'a', + ['href' => $docBaseUrl . '#' . $sectionTopDown], + $this->translate('Top Down') + ) + ))); + + $cc->addTitle('Agent deployment instructions'); + + try { + $ticket = $this->api->getTicket($host->getEndpointName()); + $wizard = new AgentWizard($host); + $wizard->setTicket($ticket); + } catch (Exception $e) { + $c->add(Hint::error(sprintf( + $this->translate( + 'A ticket for this agent could not have been requested from' + . ' your deployment endpoint: %s' + ), + $e->getMessage() + ))); + + return; + } + + $class = ['class' => 'agent-deployment-instructions']; + $c->add([ + Html::tag('h2', null, $this->translate('For manual configuration')), + Html::tag('p', null, [$this->translate('Ticket'), ': ', Html::tag('code', null, $ticket)]), + Html::tag('h2', null, $this->translate('Windows Kickstart Script')), + Link::create( + $this->translate('Download'), + $cc->url()->with('download', 'windows-kickstart'), + null, + ['class' => 'icon-download', 'target' => '_blank'] + ), + Html::tag('pre', $class, $wizard->renderWindowsInstaller()), + Html::tag('p', null, $this->translate( + 'This requires the Icinga Agent to be installed. It generates and signs' + . ' it\'s certificate and it also generates a minimal icinga2.conf to get' + . ' your agent connected to it\'s parents' + )), + Html::tag('h2', null, $this->translate('Linux commandline')), + Link::create( + $this->translate('Download'), + $cc->url()->with('download', 'linux'), + null, + ['class' => 'icon-download', 'target' => '_blank'] + ), + Html::tag('p', null, $this->translate('Just download and run this script on your Linux Client Machine:')), + Html::tag('pre', $class, $wizard->renderLinuxInstaller()) + ]); + } + + /** + * @param $os + * @throws NotFoundError + */ + public function handleLegacyAgentDownloads($os) + { + $wizard = new AgentWizard($this->host); + $wizard->setTicket($this->api->getTicket($this->host->getEndpointName())); + + switch ($os) { + case 'windows-kickstart': + $ext = 'ps1'; + $script = preg_replace('/\n/', "\r\n", $wizard->renderWindowsInstaller()); + break; + case 'linux': + $ext = 'bash'; + $script = $wizard->renderLinuxInstaller(); + break; + default: + throw new NotFoundError('There is no kickstart helper for %s', $os); + } + + header('Content-type: application/octet-stream'); + header('Content-Disposition: attachment; filename=icinga2-agent-kickstart.' . $ext); + echo $script; + exit; + } + + /** + * @return bool + */ + protected function hasDocsModuleLoaded() + { + try { + return Icinga::app()->getModuleManager()->hasLoaded('doc'); + } catch (ProgrammingError $e) { + return false; + } + } +} diff --git a/library/Director/Web/Table/ActivityLogTable.php b/library/Director/Web/Table/ActivityLogTable.php new file mode 100644 index 0000000..5460bc2 --- /dev/null +++ b/library/Director/Web/Table/ActivityLogTable.php @@ -0,0 +1,294 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\Format\LocalTimeFormat; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Module\Director\Util; +use ipl\Html\Html; +use ipl\Html\HtmlElement; + +class ActivityLogTable extends ZfQueryBasedTable +{ + protected $filters = []; + + protected $lastDeployedId; + + protected $extraParams = []; + + protected $columnCount; + + protected $hasObjectFilter = false; + + protected $searchColumns = [ + 'author', + 'object_name', + 'object_type', + ]; + + /** @var LocalTimeFormat */ + protected $timeFormat; + + protected $ranges = []; + + /** @var ?object */ + protected $currentRange = null; + /** @var ?HtmlElement */ + protected $currentRangeCell = null; + /** @var int */ + protected $rangeRows = 0; + protected $continueRange = false; + protected $currentRow; + + public function __construct($db) + { + parent::__construct($db); + $this->timeFormat = new LocalTimeFormat(); + } + + public function assemble() + { + $this->getAttributes()->add('class', 'activity-log'); + } + + public function setLastDeployedId($id) + { + $this->lastDeployedId = $id; + return $this; + } + + protected function fetchQueryRows() + { + $rows = parent::fetchQueryRows(); + // Hint -> DESC, that's why they are inverted + if (empty($rows)) { + return $rows; + } + $last = $rows[0]->id; + $first = $rows[count($rows) - 1]->id; + $db = $this->db(); + $this->ranges = $db->fetchAll( + $db->select() + ->from('director_activity_log_remark') + ->where('first_related_activity <= ?', $last) + ->where('last_related_activity >= ?', $first) + ); + + return $rows; + } + + + public function renderRow($row) + { + $this->currentRow = $row; + $this->splitByDay($row->ts_change_time); + $action = 'action-' . $row->action. ' '; + if ($row->id > $this->lastDeployedId) { + $action .= 'undeployed'; + } else { + $action .= 'deployed'; + } + + $columns = [ + $this::td($this->makeLink($row))->setSeparator(' '), + ]; + if (! $this->hasObjectFilter) { + $columns[] = $this->makeRangeInfo($row->id); + } + $columns[] = $this::td($this->timeFormat->getTime($row->ts_change_time)); + + return $this::tr($columns)->addAttributes(['class' => $action]); + } + + /** + * Hint: cloned from parent class and modified + * @param int $timestamp + */ + protected function renderDayIfNew($timestamp) + { + $day = $this->getDateFormatter()->getFullDay($timestamp); + + if ($this->lastDay !== $day) { + $this->nextHeader()->add( + $this::th($day, [ + 'colspan' => $this->hasObjectFilter ? 2 : 3, + 'class' => 'table-header-day' + ]) + ); + + $this->lastDay = $day; + if ($this->currentRangeCell) { + if ($this->currentRange->first_related_activity <= $this->currentRow->id) { + $this->currentRangeCell->addAttributes(['class' => 'continuing']); + $this->continueRange = true; + } else { + $this->continueRange = false; + } + } + $this->currentRangeCell = null; + $this->currentRange = null; + $this->rangeRows = 0; + $this->nextBody(); + } + } + + protected function makeRangeInfo($id) + { + $range = $this->getRangeForId($id); + if ($range === null) { + if ($this->currentRangeCell) { + $this->currentRangeCell->getAttributes()->remove('class', 'continuing'); + } + $this->currentRange = null; + $this->currentRangeCell = null; + $this->rangeRows = 0; + return $this::td(); + } + + if ($range === $this->currentRange) { + $this->growCurrentRange(); + return null; + } + $this->startRange($range); + + return $this->currentRangeCell; + } + + protected function startRange($range) + { + $this->currentRangeCell = $this::td($this->renderRangeComment($range), [ + 'colspan' => $this->rangeRows = 1, + 'class' => 'comment-cell' + ]); + if ($this->continueRange) { + $this->currentRangeCell->addAttributes(['class' => 'continued']); + $this->continueRange = false; + } + $this->currentRange = $range; + } + + protected function renderRangeComment($range) + { + // The only purpose of this container is to avoid hovered rows from influencing + // the comments background color, as we're using the alpha channel to lighten it + // This can be replaced once we get theme-safe colors for such messages + return Html::tag('div', [ + 'class' => 'range-comment-container', + ], Link::create($this->continueRange ? '' : $range->remark, '#', null, [ + 'title' => $range->remark, + 'class' => 'range-comment' + ])); + } + + protected function growCurrentRange() + { + $this->rangeRows++; + $this->currentRangeCell->setAttribute('rowspan', $this->rangeRows); + } + + protected function getRangeForId($id) + { + foreach ($this->ranges as $range) { + if ($id >= $range->first_related_activity && $id <= $range->last_related_activity) { + return $range; + } + } + + return null; + } + + protected function makeLink($row) + { + $type = $row->object_type; + $name = $row->object_name; + if (substr($type, 0, 7) === 'icinga_') { + $type = substr($type, 7); + } + + if (Util::hasPermission('director/showconfig')) { + // Later on replacing, service_set -> serviceset + + // multi column key :( + if ($type === 'service' || $this->hasObjectFilter) { + $object = "\"$name\""; + } else { + $object = Link::create( + "\"$name\"", + 'director/' . str_replace('_', '', $type), + ['name' => $name], + ['title' => $this->translate('Jump to this object')] + ); + } + + return [ + '[' . $row->author . ']', + Link::create( + $row->action, + 'director/config/activity', + array_merge(['id' => $row->id], $this->extraParams), + ['title' => $this->translate('Show details related to this change')] + ), + str_replace('_', ' ', $type), + $object + ]; + } else { + return sprintf( + '[%s] %s %s "%s"', + $row->author, + $row->action, + $type, + $name + ); + } + } + + public function filterObject($type, $name) + { + $this->hasObjectFilter = true; + $this->filters[] = ['l.object_type = ?', $type]; + $this->filters[] = ['l.object_name = ?', $name]; + + return $this; + } + + public function filterHost($name) + { + $db = $this->db(); + $filter = '%"host":' . json_encode($name) . '%'; + $this->filters[] = ['(' + . $db->quoteInto('l.old_properties LIKE ?', $filter) + . ' OR ' + . $db->quoteInto('l.new_properties LIKE ?', $filter) + . ')', null]; + + return $this; + } + + public function getColumns() + { + return [ + 'author' => 'l.author', + 'action' => 'l.action_name', + 'object_name' => 'l.object_name', + 'object_type' => 'l.object_type', + 'id' => 'l.id', + 'change_time' => 'l.change_time', + 'ts_change_time' => 'UNIX_TIMESTAMP(l.change_time)', + ]; + } + + public function prepareQuery() + { + $query = $this->db()->select()->from( + ['l' => 'director_activity_log'], + $this->getColumns() + )->order('change_time DESC')->order('id DESC')->limit(100); + + foreach ($this->filters as $filter) { + $query->where($filter[0], $filter[1]); + } + + return $query; + } +} diff --git a/library/Director/Web/Table/ApplyRulesTable.php b/library/Director/Web/Table/ApplyRulesTable.php new file mode 100644 index 0000000..a861bac --- /dev/null +++ b/library/Director/Web/Table/ApplyRulesTable.php @@ -0,0 +1,240 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Db\IcingaObjectFilterHelper; +use Icinga\Module\Director\IcingaConfig\AssignRenderer; +use Icinga\Module\Director\Objects\IcingaObject; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; +use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer; +use Ramsey\Uuid\Uuid; +use Zend_Db_Select as ZfSelect; + +class ApplyRulesTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'o.object_name', + 'o.assign_filter', + ]; + + private $type; + + /** @var IcingaObject */ + protected $dummyObject; + + protected $baseObjectUrl; + + protected $linkWithName = false; + + public static function create($type, Db $db) + { + $table = new static($db); + $table->setType($type); + return $table; + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setBaseObjectUrl($url) + { + $this->baseObjectUrl = $url; + + return $this; + } + + public function createLinksWithNames($linksWithName = true) + { + $this->linkWithName = (bool) $linksWithName; + + return $this; + } + + public function getType() + { + return $this->type; + } + + public function getColumnsToBeRendered() + { + return ['Name', 'assign where'/*, 'Actions'*/]; + } + + public function renderRow($row) + { + $row->uuid = DbUtil::binaryResult($row->uuid); + if ($this->linkWithName) { + $params = ['name' => $row->object_name]; + } else { + $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()]; + } + $url = Url::fromPath("director/{$this->baseObjectUrl}/edit", $params); + + $assignWhere = $this->renderApplyFilter($row->assign_filter); + + if (! empty($row->apply_for)) { + $assignWhere = sprintf('apply for %s / %s', $row->apply_for, $assignWhere); + } + + $tr = static::tr([ + static::td(Link::create($row->object_name, $url)), + static::td($assignWhere), + // NOT (YET) static::td($this->createActionLinks($row))->setSeparator(' ') + ]); + + if ($row->disabled === 'y') { + $tr->getAttributes()->add('class', 'disabled'); + } + + return $tr; + } + + /** + * Should be triggered from renderRow, still unused. + * + * @param IcingaObject $template + * @param string $inheritance + * @return $this + * @throws \Icinga\Exception\ProgrammingError + */ + public function filterTemplate( + IcingaObject $template, + $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT + ) { + IcingaObjectFilterHelper::filterByTemplate( + $this->getQuery(), + $template, + 'o', + $inheritance + ); + + return $this; + } + + protected function renderApplyFilter($assignFilter) + { + try { + $string = AssignRenderer::forFilter( + Filter::fromQueryString($assignFilter) + )->renderAssign(); + // Do not prefix it + $string = preg_replace('/^assign where /', '', $string); + } catch (IcingaException $e) { + // ignore errors in filter rendering + $string = 'Error in Filter rendering: ' . $e->getMessage(); + } + + return $string; + } + + public function createActionLinks($row) + { + $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()]; + $baseUrl = 'director/' . $this->baseObjectUrl; + $links = []; + $links[] = Link::create( + Icon::create('sitemap'), + "${baseUrl}template/applytargets", + ['id' => $row->id], + ['title' => $this->translate('Show affected Objects')] + ); + + $links[] = Link::create( + Icon::create('edit'), + "$baseUrl/edit", + $params, + ['title' => $this->translate('Modify this Apply Rule')] + ); + + $links[] = Link::create( + Icon::create('doc-text'), + "$baseUrl/render", + $params, + ['title' => $this->translate('Apply Rule rendering preview')] + ); + + $links[] = Link::create( + Icon::create('history'), + "$baseUrl/history", + $params, + ['title' => $this->translate('Apply rule history')] + ); + + return $links; + } + + protected function applyRestrictions(ZfSelect $query) + { + $auth = Auth::getInstance(); + $type = $this->type; + // TODO: Centralize this logic + if ($type === 'scheduledDowntime') { + $type = 'scheduled-downtime'; + } + $restrictions = $auth->getRestrictions("director/$type/apply/filter-by-name"); + if (empty($restrictions)) { + return $query; + } + + $filter = Filter::matchAny(); + foreach ($restrictions as $restriction) { + $filter->addFilter(Filter::where('o.object_name', $restriction)); + } + + return FilterRenderer::applyToQuery($filter, $query); + } + + + /** + * @return IcingaObject + */ + protected function getDummyObject() + { + if ($this->dummyObject === null) { + $type = $this->type; + $this->dummyObject = IcingaObject::createByType($type); + } + return $this->dummyObject; + } + + public function prepareQuery() + { + $table = $this->getDummyObject()->getTableName(); + $columns = [ + 'id' => 'o.id', + 'uuid' => 'o.uuid', + 'object_name' => 'o.object_name', + 'disabled' => 'o.disabled', + 'assign_filter' => 'o.assign_filter', + 'apply_for' => '(NULL)', + ]; + + if ($table === 'icinga_service') { + $columns['apply_for'] = 'o.apply_for'; + } + $query = $this->db()->select()->from( + ['o' => $table], + $columns + )->where( + "object_type = 'apply'" + )->order('o.object_name'); + + if ($this->type === 'service') { + $query->where('service_set_id IS NULL'); + } + + return $this->applyRestrictions($query); + } +} diff --git a/library/Director/Web/Table/BasketSnapshotTable.php b/library/Director/Web/Table/BasketSnapshotTable.php new file mode 100644 index 0000000..08f808a --- /dev/null +++ b/library/Director/Web/Table/BasketSnapshotTable.php @@ -0,0 +1,125 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Date\DateFormatter; +use Icinga\Module\Director\Core\Json; +use Icinga\Module\Director\DirectorObject\Automation\Basket; +use RuntimeException; + +class BasketSnapshotTable extends ZfQueryBasedTable +{ + use DbHelper; + + protected $searchColumns = [ + 'basket_name', + 'summary' + ]; + + /** @var Basket */ + protected $basket; + + public function setBasket(Basket $basket) + { + $this->basket = $basket; + $this->searchColumns = []; + + return $this; + } + + public function renderRow($row) + { + $this->splitByDay($row->ts_create_seconds); + $link = $this->linkToSnapshot($this->renderSummary($row->summary), $row); + + if ($this->basket === null) { + $columns = [ + [ + new Link( + Html::tag('strong', $row->basket_name), + 'director/basket', + ['name' => $row->basket_name] + ), + Html::tag('br'), + $link, + ], + DateFormatter::formatTime($row->ts_create / 1000), + ]; + } else { + $columns = [ + $link, + DateFormatter::formatTime($row->ts_create / 1000), + ]; + } + return $this::row($columns); + } + + protected function renderSummary($summary) + { + $summary = Json::decode($summary); + if ($summary === null) { + return '-'; + } + $result = []; + if (! is_object($summary) && ! is_array($summary)) { + throw new RuntimeException(sprintf( + 'Got invalid basket summary: %s ', + var_export($summary, 1) + )); + } + + foreach ($summary as $type => $count) { + $result[] = sprintf( + '%dx %s', + $count, + $type + ); + } + + if (empty($result)) { + return '-'; + } + + return implode(', ', $result); + } + + protected function linkToSnapshot($caption, $row) + { + return new Link($caption, 'director/basket/snapshot', [ + 'checksum' => bin2hex($this->wantBinaryValue($row->content_checksum)), + 'ts' => $row->ts_create, + 'name' => $row->basket_name, + ]); + } + + public function prepareQuery() + { + $query = $this->db()->select()->from([ + 'b' => 'director_basket' + ], [ + 'b.uuid', + 'b.basket_name', + 'bs.ts_create', + 'ts_create_seconds' => '(bs.ts_create / 1000)', + 'bs.content_checksum', + 'bc.summary', + ])->join( + ['bs' => 'director_basket_snapshot'], + 'bs.basket_uuid = b.uuid', + [] + )->join( + ['bc' => 'director_basket_content'], + 'bc.checksum = bs.content_checksum', + [] + )->order('bs.ts_create DESC'); + + if ($this->basket !== null) { + $query->where('b.uuid = ?', $this->quoteBinary($this->basket->get('uuid'))); + } + + return $query; + } +} diff --git a/library/Director/Web/Table/BasketTable.php b/library/Director/Web/Table/BasketTable.php new file mode 100644 index 0000000..25e37e0 --- /dev/null +++ b/library/Director/Web/Table/BasketTable.php @@ -0,0 +1,50 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class BasketTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'basket_name', + ]; + + public function renderRow($row) + { + $tr = $this::row([ + new Link( + $row->basket_name, + 'director/basket', + ['name' => $row->basket_name] + ), + $row->cnt_snapshots + ]); + + return $tr; + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Basket'), + $this->translate('Snapshots'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from([ + 'b' => 'director_basket' + ], [ + 'b.uuid', + 'b.basket_name', + 'cnt_snapshots' => 'COUNT(bs.basket_uuid)', + ])->joinLeft( + ['bs' => 'director_basket_snapshot'], + 'bs.basket_uuid = b.uuid', + [] + )->group('b.uuid')->order('b.basket_name'); + } +} diff --git a/library/Director/Web/Table/BranchActivityTable.php b/library/Director/Web/Table/BranchActivityTable.php new file mode 100644 index 0000000..e7131ef --- /dev/null +++ b/library/Director/Web/Table/BranchActivityTable.php @@ -0,0 +1,116 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\Format\LocalTimeFormat; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\BranchActivity; +use Icinga\Module\Director\Util; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Ramsey\Uuid\UuidInterface; + +class BranchActivityTable extends ZfQueryBasedTable +{ + protected $extraParams = []; + + /** @var UuidInterface */ + protected $branchUuid; + + /** @var ?UuidInterface */ + protected $objectUuid; + + /** @var LocalTimeFormat */ + protected $timeFormat; + + protected $linkToObject = true; + + public function __construct(UuidInterface $branchUuid, $db, UuidInterface $objectUuid = null) + { + $this->branchUuid = $branchUuid; + $this->objectUuid = $objectUuid; + $this->timeFormat = new LocalTimeFormat(); + parent::__construct($db); + } + + public function assemble() + { + $this->getAttributes()->add('class', 'activity-log'); + } + + public function renderRow($row) + { + $ts = (int) floor(BranchActivity::fixFakeTimestamp($row->timestamp_ns) / 1000000); + $this->splitByDay($ts); + $activity = BranchActivity::fromDbRow($row); + return $this::tr([ + $this::td($this->makeBranchLink($activity))->setSeparator(' '), + $this::td($this->timeFormat->getTime($ts)) + ])->addAttributes(['class' => ['action-' . $activity->getAction(), 'branched']]); + } + + public function disableObjectLink() + { + $this->linkToObject = false; + return $this; + } + + protected function linkObject(BranchActivity $activity) + { + if (! $this->linkToObject) { + return $activity->getObjectName(); + } + // $type, UuidInterface $uuid + // Later on replacing, service_set -> serviceset + $type = preg_replace('/^icinga_/', '', $activity->getObjectTable()); + return Link::create( + $activity->getObjectName(), + 'director/' . str_replace('_', '', $type), + ['uuid' => $activity->getObjectUuid()->toString()], + ['title' => $this->translate('Jump to this object')] + ); + } + + protected function makeBranchLink(BranchActivity $activity) + { + $type = preg_replace('/^icinga_/', '', $activity->getObjectTable()); + + if (Util::hasPermission('director/showconfig')) { + // Later on replacing, service_set -> serviceset + return [ + '[' . $activity->getAuthor() . ']', + Link::create( + $activity->getAction(), + 'director/branch/activity', + array_merge(['ts' => $activity->getTimestampNs()], $this->extraParams), + ['title' => $this->translate('Show details related to this change')] + ), + str_replace('_', ' ', $type), + $this->linkObject($activity) + ]; + } else { + return sprintf( + '[%s] %s %s "%s"', + $activity->getAuthor(), + $activity->getAction(), + $type, + $activity->getObjectName() + ); + } + } + + public function prepareQuery() + { + /** @var Db $connection */ + $connection = $this->connection(); + $query = $this->db()->select()->from(['ba' => 'director_branch_activity'], 'ba.*') + ->join(['b' => 'director_branch'], 'b.uuid = ba.branch_uuid', ['b.owner']) + ->where('branch_uuid = ?', $connection->quoteBinary($this->branchUuid->getBytes())) + ->order('timestamp_ns DESC'); + if ($this->objectUuid) { + $query->where('ba.object_uuid = ?', $connection->quoteBinary($this->objectUuid->getBytes())); + } + + return $query; + } +} diff --git a/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php new file mode 100644 index 0000000..3d5dbcb --- /dev/null +++ b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php @@ -0,0 +1,78 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter; +use gipfl\IcingaWeb2\Table\QueryBasedTable; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Objects\IcingaCommand; +use gipfl\IcingaWeb2\Link; + +class BranchedIcingaCommandArgumentTable extends QueryBasedTable +{ + /** @var IcingaCommand */ + protected $command; + + /** @var Branch */ + protected $branch; + + protected $searchColumns = [ + 'ca.argument_name', + 'ca.argument_value', + ]; + + public function __construct(IcingaCommand $command, Branch $branch) + { + $this->command = $command; + $this->branch = $branch; + $this->getAttributes()->set('data-base-target', '_self'); + } + + public function renderRow($row) + { + return $this::row([ + Link::create($row->argument_name, 'director/command/arguments', [ + 'argument' => $row->argument_name, + 'uuid' => $this->command->getUniqueId()->toString(), + ]), + $row->argument_value + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Argument'), + $this->translate('Value'), + ]; + } + + protected function getPaginationAdapter() + { + return new SimpleQueryPaginationAdapter($this->getQuery()); + } + + public function getQuery() + { + return $this->prepareQuery(); + } + + protected function fetchQueryRows() + { + return $this->getQuery()->fetchAll(); + } + + protected function prepareQuery() + { + $list = []; + foreach ($this->command->arguments()->toPlainObject() as $name => $argument) { + $new = (object) []; + $new->argument_name = $name; + $new->argument_value = isset($argument->value) ? $argument->value : null; + $list[] = $new; + } + + return (new ArrayDatasource($list))->select(); + } +} diff --git a/library/Director/Web/Table/ChoicesTable.php b/library/Director/Web/Table/ChoicesTable.php new file mode 100644 index 0000000..4ba2460 --- /dev/null +++ b/library/Director/Web/Table/ChoicesTable.php @@ -0,0 +1,65 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; + +class ChoicesTable extends ZfQueryBasedTable +{ + protected $searchColumns = ['o.object_name']; + + protected $type; + + /** + * @param $type + * @param Db $db + * @return static + */ + public static function create($type, Db $db) + { + $class = __NAMESPACE__ . '\\ChoicesTable' . ucfirst($type); + if (! class_exists($class)) { + $class = __CLASS__; + } + + /** @var static $table */ + $table = new $class($db); + $table->type = $type; + return $table; + } + + public function getType() + { + return $this->type; + } + + public function getColumnsToBeRendered() + { + return [$this->translate('Name')]; + } + + public function renderRow($row) + { + $type = $this->getType(); + $url = Url::fromPath("director/templatechoice/${type}", [ + 'name' => $row->object_name + ]); + + return $this::row([ + Link::create($row->object_name, $url) + ]); + } + + protected function prepareQuery() + { + $type = $this->getType(); + $table = "icinga_${type}_template_choice"; + return $this->db() + ->select() + ->from(['o' => $table], 'object_name') + ->order('o.object_name'); + } +} diff --git a/library/Director/Web/Table/ConfigFileDiffTable.php b/library/Director/Web/Table/ConfigFileDiffTable.php new file mode 100644 index 0000000..1d14d5e --- /dev/null +++ b/library/Director/Web/Table/ConfigFileDiffTable.php @@ -0,0 +1,140 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Util; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class ConfigFileDiffTable extends ZfQueryBasedTable +{ + use DbHelper; + + protected $leftChecksum; + + protected $rightChecksum; + + /** + * @param $leftSum + * @param $rightSum + * @param Db $connection + * @return static + */ + public static function load($leftSum, $rightSum, Db $connection) + { + $table = new static($connection); + $table->getAttributes()->add('class', 'config-diff'); + return $table->setLeftChecksum($leftSum) + ->setRightChecksum($rightSum); + } + + public function renderRow($row) + { + $tr = $this::row([ + $this->getFileFiffLink($row), + $row->file_path, + ]); + + $tr->getAttributes()->add('class', 'file-' . $row->file_action); + return $tr; + } + + protected function getFileFiffLink($row) + { + $params = array('file_path' => $row->file_path); + + if ($row->file_checksum_left === $row->file_checksum_right) { + $params['config_checksum'] = $row->config_checksum_right; + } elseif ($row->file_checksum_left === null) { + $params['config_checksum'] = $row->config_checksum_right; + } elseif ($row->file_checksum_right === null) { + $params['config_checksum'] = $row->config_checksum_left; + } else { + $params['left'] = $row->config_checksum_left; + $params['right'] = $row->config_checksum_right; + return Link::create( + $row->file_action, + 'director/config/filediff', + $params + ); + } + + return Link::create($row->file_action, 'director/config/file', $params); + } + + public function setLeftChecksum($checksum) + { + $this->leftChecksum = $checksum; + return $this; + } + + public function setRightChecksum($checksum) + { + $this->rightChecksum = $checksum; + return $this; + } + + public function getTitles() + { + return array( + $this->translate('Action'), + $this->translate('File'), + ); + } + + public function prepareQuery() + { + $db = $this->db(); + + $left = $db->select() + ->from( + array('cfl' => 'director_generated_config_file'), + array( + 'file_path' => 'COALESCE(cfl.file_path, cfr.file_path)', + 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'), + 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'), + 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'), + 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'), + 'file_action' => '(CASE WHEN cfr.config_checksum IS NULL' + . " THEN 'removed' WHEN cfl.file_checksum = cfr.file_checksum" + . " THEN 'unmodified' ELSE 'modified' END)", + ) + )->joinLeft( + array('cfr' => 'director_generated_config_file'), + $db->quoteInto( + 'cfl.file_path = cfr.file_path AND cfr.config_checksum = ?', + $this->quoteBinary(hex2bin($this->rightChecksum)) + ), + array() + )->where( + 'cfl.config_checksum = ?', + $this->quoteBinary(hex2bin($this->leftChecksum)) + ); + + $right = $db->select() + ->from( + array('cfl' => 'director_generated_config_file'), + array( + 'file_path' => 'COALESCE(cfr.file_path, cfl.file_path)', + 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'), + 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'), + 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'), + 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'), + 'file_action' => "('created')", + ) + )->joinRight( + array('cfr' => 'director_generated_config_file'), + $db->quoteInto( + 'cfl.file_path = cfr.file_path AND cfl.config_checksum = ?', + $this->quoteBinary(hex2bin($this->leftChecksum)) + ), + array() + )->where( + 'cfr.config_checksum = ?', + $this->quoteBinary(hex2bin($this->rightChecksum)) + )->where('cfl.file_checksum IS NULL'); + + return $db->select()->union(array($left, $right))->order('file_path'); + } +} diff --git a/library/Director/Web/Table/CoreApiFieldsTable.php b/library/Director/Web/Table/CoreApiFieldsTable.php new file mode 100644 index 0000000..24a6521 --- /dev/null +++ b/library/Director/Web/Table/CoreApiFieldsTable.php @@ -0,0 +1,106 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use ipl\Html\Table; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; + +class CoreApiFieldsTable extends Table +{ + use TranslationHelper; + + protected $defaultAttributes = [ + 'class' => ['common-table'/*, 'table-row-selectable'*/], + //'data-base-target' => '_next', + ]; + + protected $fields; + + /** @var Url */ + protected $url; + + public function __construct($fields, Url $url) + { + $this->url = $url; + $this->fields = $fields; + } + + public function assemble() + { + if (empty($this->fields)) { + return; + } + $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th')))); + foreach ($this->fields as $name => $field) { + $tr = $this::tr([ + $this::td($name), + $this::td(Link::create( + $field->type, + $this->url->with('type', $field->type) + )), + $this::td($field->id) + // $this::td($field->array_rank), + // $this::td($this->renderKeyValue($field->attributes)) + ]); + $this->addAttributeColumns($tr, $field->attributes); + $this->add($tr); + } + } + + protected function addAttributeColumns(BaseHtmlElement $tr, $attrs) + { + $tr->add([ + $this->makeBooleanColumn($attrs->state), + $this->makeBooleanColumn($attrs->config), + $this->makeBooleanColumn($attrs->required), + $this->makeBooleanColumn(isset($attrs->deprecated) ? $attrs->deprecated : null), + $this->makeBooleanColumn($attrs->no_user_modify), + $this->makeBooleanColumn($attrs->no_user_view), + $this->makeBooleanColumn($attrs->navigation), + ]); + } + + protected function makeBooleanColumn($value) + { + if ($value === null) { + return $this::td('-'); + } + + return $this::td($value ? Html::tag('strong', 'true') : 'false'); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Name'), + $this->translate('Type'), + $this->translate('Id'), + // $this->translate('Array Rank'), + // $this->translate('Attributes') + $this->translate('State'), + $this->translate('Config'), + $this->translate('Required'), + $this->translate('Deprecated'), + $this->translate('Protected'), + $this->translate('Hidden'), + $this->translate('Nav'), + ]; + } + + protected function renderKeyValue($values) + { + $parts = []; + foreach ((array) $values as $key => $value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + $parts[] = "$key: $value"; + } + + return implode(', ', $parts); + } +} diff --git a/library/Director/Web/Table/CoreApiObjectsTable.php b/library/Director/Web/Table/CoreApiObjectsTable.php new file mode 100644 index 0000000..c2cefea --- /dev/null +++ b/library/Director/Web/Table/CoreApiObjectsTable.php @@ -0,0 +1,60 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\IcingaEndpoint; +use gipfl\IcingaWeb2\Link; +use ipl\Html\Html; +use ipl\Html\Table; +use gipfl\Translation\TranslationHelper; + +class CoreApiObjectsTable extends Table +{ + use TranslationHelper; + + protected $defaultAttributes = [ + 'class' => ['common-table', 'table-row-selectable'], + 'data-base-target' => '_next', + ]; + + /** @var IcingaEndpoint */ + protected $endpoint; + + protected $objects; + + protected $type; + + public function __construct($objects, IcingaEndpoint $endpoint, $type) + { + $this->objects = $objects; + $this->endpoint = $endpoint; + $this->type = $type; + } + + public function assemble() + { + if (empty($this->objects)) { + return; + } + $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th')))); + foreach ($this->objects as $name) { + $this->add($this::tr($this::td(Link::create( + str_replace('!', ': ', $name), + 'director/inspect/object', + [ + 'name' => $name, + 'type' => $this->type->name, + 'plural' => $this->type->plural_name, + 'endpoint' => $this->endpoint->getObjectName() + ] + )))); + } + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Name'), + ]; + } +} diff --git a/library/Director/Web/Table/CoreApiPrototypesTable.php b/library/Director/Web/Table/CoreApiPrototypesTable.php new file mode 100644 index 0000000..78fd964 --- /dev/null +++ b/library/Director/Web/Table/CoreApiPrototypesTable.php @@ -0,0 +1,43 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use ipl\Html\Table; +use gipfl\Translation\TranslationHelper; + +class CoreApiPrototypesTable extends Table +{ + use TranslationHelper; + + protected $defaultAttributes = ['class' => ['common-table']]; + + protected $prototypes; + + protected $typeName; + + public function __construct($prototypes, $typeName) + { + $this->prototypes = $prototypes; + $this->typeName = $typeName; + } + + public function assemble() + { + if (empty($this->prototypes)) { + return; + } + $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th')))); + $type = $this->typeName; + foreach ($this->prototypes as $name) { + $this->add($this::tr($this::td("$type.$name()"))); + } + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Name'), + ]; + } +} diff --git a/library/Director/Web/Table/CustomvarTable.php b/library/Director/Web/Table/CustomvarTable.php new file mode 100644 index 0000000..f9a3844 --- /dev/null +++ b/library/Director/Web/Table/CustomvarTable.php @@ -0,0 +1,102 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Zend_Db_Adapter_Abstract as ZfDbAdapter; +use Zend_Db_Select as ZfDbSelect; + +class CustomvarTable extends ZfQueryBasedTable +{ + protected $searchColumns = array( + 'varname', + ); + + public function renderRow($row) + { + $tr = $this::row([ + new Link( + $row->varname, + 'director/customvar/variants', + ['name' => $row->varname] + ) + ]); + + foreach ($this->getObjectTypes() as $type) { + $tr->add($this::td(Html::tag('nobr', null, sprintf( + $this->translate('%d / %d'), + $row->{"cnt_$type"}, + $row->{"distinct_$type"} + )))); + } + + return $tr; + } + + public function getColumnsToBeRendered() + { + return array( + $this->translate('Variable name'), + $this->translate('Distinct Commands'), + $this->translate('Hosts'), + $this->translate('Services'), + $this->translate('Service Sets'), + $this->translate('Notifications'), + $this->translate('Users'), + ); + } + + protected function getObjectTypes() + { + return ['command', 'host', 'service', 'service_set', 'notification', 'user']; + } + + public function prepareQuery() + { + $db = $this->db(); + $varsColumns = ['varname' => 'v.varname']; + $varsTypes = $this->getObjectTypes(); + foreach ($varsTypes as $type) { + $varsColumns["cnt_$type"] = '(0)'; + $varsColumns["distinct_$type"] = '(0)'; + } + $varsQueries = []; + foreach ($varsTypes as $type) { + $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db); + } + + $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL); + + $columns = ['varname' => 'u.varname']; + foreach ($varsTypes as $column) { + $columns["cnt_$column"] = "SUM(u.cnt_$column)"; + $columns["distinct_$column"] = "SUM(u.distinct_$column)"; + } + return $db->select()->from( + array('u' => $union), + $columns + )->group('u.varname')->order('u.varname ASC')->limit(100); + } + + /** + * @param string $type + * @param array $columns + * @param ZfDbAdapter $db + * @return ZfDbSelect + */ + protected function makeVarSub($type, array $columns, ZfDbAdapter $db) + { + $columns["cnt_$type"] = 'COUNT(*)'; + $columns["distinct_$type"] = 'COUNT(DISTINCT varvalue)'; + return $db->select()->from( + ['v' => "icinga_${type}_var"], + $columns + )->join( + ['o' => "icinga_${type}"], + "o.id = v.${type}_id", + [] + )->where('o.object_type != ?', 'external_object')->group('varname'); + } +} diff --git a/library/Director/Web/Table/CustomvarVariantsTable.php b/library/Director/Web/Table/CustomvarVariantsTable.php new file mode 100644 index 0000000..80fca70 --- /dev/null +++ b/library/Director/Web/Table/CustomvarVariantsTable.php @@ -0,0 +1,125 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\PlainObjectRenderer; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Zend_Db_Adapter_Abstract as ZfDbAdapter; +use Zend_Db_Select as ZfDbSelect; + +class CustomvarVariantsTable extends ZfQueryBasedTable +{ + protected $searchColumns = ['varvalue']; + + protected $varName; + + public static function create(Db $db, $varName) + { + $table = new static($db); + $table->varName = $varName; + $table->getAttributes()->set('class', 'common-table'); + return $table; + } + + public function renderRow($row) + { + if ($row->format === 'json') { + $value = PlainObjectRenderer::render(json_decode($row->varvalue)); + } else { + $value = $row->varvalue; + } + $tr = $this::row([ + /* new Link( + $value, + 'director/customvar/value', + ['name' => $row->varvalue] + )*/ + $value + ]); + + foreach ($this->getObjectTypes() as $type) { + $cnt = (int) $row->{"cnt_$type"}; + if ($cnt === 0) { + $cnt = '-'; + } + $tr->add($this::td($cnt)); + } + + return $tr; + } + + public function getColumnsToBeRendered() + { + return array( + $this->translate('Variable Value'), + $this->translate('Commands'), + $this->translate('Hosts'), + $this->translate('Services'), + $this->translate('Service Sets'), + $this->translate('Notifications'), + $this->translate('Users'), + ); + } + + protected function getObjectTypes() + { + return ['command', 'host', 'service', 'service_set', 'notification', 'user']; + } + + public function prepareQuery() + { + $db = $this->db(); + $varsColumns = ['varvalue' => 'v.varvalue']; + $varsTypes = $this->getObjectTypes(); + foreach ($varsTypes as $type) { + $varsColumns["cnt_$type"] = '(0)'; + } + $varsQueries = []; + foreach ($varsTypes as $type) { + $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db); + } + + $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL); + + $columns = [ + 'varvalue' => 'u.varvalue', + 'format' => 'u.format', + ]; + foreach ($varsTypes as $column) { + $columns["cnt_$column"] = "SUM(u.cnt_$column)"; + } + return $db->select()->from(['u' => $union], $columns) + ->group('u.varvalue')->group('u.format') + ->order('u.varvalue ASC') + ->order('u.format ASC') + ->limit(100); + } + + /** + * @param string $type + * @param array $columns + * @param ZfDbAdapter $db + * @return ZfDbSelect + */ + protected function makeVarSub($type, array $columns, ZfDbAdapter $db) + { + $columns["cnt_$type"] = 'COUNT(*)'; + $columns['format'] = 'v.format'; + return $db->select()->from( + ['v' => "icinga_${type}_var"], + $columns + )->join( + ['o' => "icinga_${type}"], + "o.id = v.${type}_id", + [] + )->where( + 'v.varname = ?', + $this->varName + )->where( + 'o.object_type != ?', + 'external_object' + )->group('varvalue')->group('v.format'); + } +} diff --git a/library/Director/Web/Table/DatafieldCategoryTable.php b/library/Director/Web/Table/DatafieldCategoryTable.php new file mode 100644 index 0000000..6f07939 --- /dev/null +++ b/library/Director/Web/Table/DatafieldCategoryTable.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use ipl\Html\Html; + +class DatafieldCategoryTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'dfc.category_name', + 'dfc.description', + ]; + + public function getColumns() + { + return array( + 'id' => 'dfc.id', + 'category_name' => 'dfc.category_name', + 'description' => 'dfc.description', + 'assigned_fields' => 'COUNT(df.id)', + ); + } + + public function renderRow($row) + { + $main = [Link::create( + $row->category_name, + 'director/datafieldcategory/edit', + ['name' => $row->category_name] + )]; + + if ($row->description !== null && strlen($row->description)) { + $main[] = Html::tag('br'); + $main[] = Html::tag('small', $row->description); + } + return $this::tr([ + $this::td($main), + $this::td($row->assigned_fields) + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Category Name'), + $this->translate('# Used'), + ]; + } + + public function prepareQuery() + { + $db = $this->db(); + return $db->select()->from( + ['dfc' => 'director_datafield_category'], + $this->getColumns() + )->joinLeft( + ['df' => 'director_datafield'], + 'df.category_id = dfc.id', + [] + )->group('dfc.id')->group('dfc.category_name')->order('category_name ASC'); + } +} diff --git a/library/Director/Web/Table/DatafieldTable.php b/library/Director/Web/Table/DatafieldTable.php new file mode 100644 index 0000000..4b321d7 --- /dev/null +++ b/library/Director/Web/Table/DatafieldTable.php @@ -0,0 +1,118 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Zend_Db_Adapter_Abstract as ZfDbAdapter; +use Zend_Db_Select as ZfDbSelect; + +class DatafieldTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'df.varname', + 'df.caption', + ]; + + public function getColumns() + { + return [ + 'id' => 'df.id', + 'varname' => 'df.varname', + 'caption' => 'df.caption', + 'description' => 'df.description', + 'datatype' => 'df.datatype', + 'category' => 'dfc.category_name', + 'assigned_fields' => 'SUM(used_fields.cnt)', + 'assigned_vars' => 'SUM(used_vars.cnt)', + ]; + } + + public function renderRow($row) + { + return $this::tr([ + $this::td(Link::create( + $row->caption, + 'director/datafield/edit', + ['id' => $row->id] + )), + $this::td($row->varname), + $this::td($row->category), + $this::td($row->assigned_fields), + $this::td($row->assigned_vars) + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Label'), + $this->translate('Field name'), + $this->translate('Category'), + $this->translate('# Used'), + $this->translate('# Vars'), + ]; + } + + public function prepareQuery() + { + $db = $this->db(); + $fieldTypes = ['command', 'host', 'notification', 'service', 'user']; + $varsTypes = ['command', 'host', 'notification', 'service', 'service_set', 'user']; + + $fieldsQueries = []; + foreach ($fieldTypes as $type) { + $fieldsQueries[] = $this->makeDatafieldSub($type, $db); + } + + $varsQueries = []; + foreach ($varsTypes as $type) { + $varsQueries[] = $this->makeVarSub($type, $db); + } + + return $db->select()->from( + ['df' => 'director_datafield'], + $this->getColumns() + )->joinLeft( + ['dfc' => 'director_datafield_category'], + 'df.category_id = dfc.id', + [] + )->joinLeft( + ['used_fields' => $db->select()->union($fieldsQueries, ZfDbSelect::SQL_UNION_ALL)], + 'used_fields.datafield_id = df.id', + [] + )->joinLeft( + ['used_vars' => $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL)], + 'used_vars.varname = df.varname', + [] + )->group('df.id')->group('df.varname')->group('dfc.category_name')->order('caption ASC'); + } + + /** + * @param $type + * @param ZfDbAdapter $db + * + * @return ZfDbSelect + */ + protected function makeDatafieldSub($type, ZfDbAdapter $db) + { + return $db->select()->from("icinga_${type}_field", [ + 'cnt' => 'COUNT(*)', + 'datafield_id' + ])->group('datafield_id'); + } + + /** + * @param $type + * @param ZfDbAdapter $db + * + * @return ZfDbSelect + */ + protected function makeVarSub($type, ZfDbAdapter $db) + { + return $db->select()->from("icinga_${type}_var", [ + 'cnt' => 'COUNT(*)', + 'varname' + ])->group('varname'); + } +} diff --git a/library/Director/Web/Table/DatalistEntryTable.php b/library/Director/Web/Table/DatalistEntryTable.php new file mode 100644 index 0000000..70167c7 --- /dev/null +++ b/library/Director/Web/Table/DatalistEntryTable.php @@ -0,0 +1,73 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\DirectorDatalist; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class DatalistEntryTable extends ZfQueryBasedTable +{ + protected $datalist; + + protected $searchColumns = [ + 'entry_name', + 'entry_value' + ]; + + public function setList(DirectorDatalist $list) + { + $this->datalist = $list; + + return $this; + } + + public function getList() + { + return $this->datalist; + } + + public function getColumns() + { + return [ + 'list_name' => 'l.list_name', + 'list_id' => 'le.list_id', + 'entry_name' => 'le.entry_name', + 'entry_value' => 'le.entry_value', + ]; + } + + public function renderRow($row) + { + return $this::tr([ + $this::td(Link::create($row->entry_name, 'director/data/listentry/edit', [ + 'list' => $row->list_name, + 'entry_name' => $row->entry_name, + ])), + $this::td($row->entry_value) + ]); + } + + public function getColumnsToBeRendered() + { + return [ + 'entry_name' => $this->translate('Key'), + 'entry_value' => $this->translate('Label'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['le' => 'director_datalist_entry'], + $this->getColumns() + )->join( + ['l' => 'director_datalist'], + 'l.id = le.list_id', + [] + )->where( + 'le.list_id = ?', + $this->getList()->id + )->order('le.entry_name ASC'); + } +} diff --git a/library/Director/Web/Table/DatalistTable.php b/library/Director/Web/Table/DatalistTable.php new file mode 100644 index 0000000..7b35fe0 --- /dev/null +++ b/library/Director/Web/Table/DatalistTable.php @@ -0,0 +1,41 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class DatalistTable extends ZfQueryBasedTable +{ + protected $searchColumns = ['list_name']; + + public function getColumns() + { + return [ + 'id' => 'l.id', + 'list_name' => 'l.list_name', + ]; + } + + public function renderRow($row) + { + return $this::tr($this::td(Link::create( + $row->list_name, + 'director/data/listentry', + array('list' => $row->list_name) + ))); + } + + public function getColumnsToBeRendered() + { + return [$this->translate('List name')]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['l' => 'director_datalist'], + $this->getColumns() + )->order('list_name ASC'); + } +} diff --git a/library/Director/Web/Table/DbHelper.php b/library/Director/Web/Table/DbHelper.php new file mode 100644 index 0000000..573f946 --- /dev/null +++ b/library/Director/Web/Table/DbHelper.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Zend_Db_Expr as Expr; + +trait DbHelper +{ + public function dbHexFunc($column) + { + if ($this->isPgsql()) { + return sprintf("LOWER(ENCODE(%s, 'hex'))", $column); + } else { + return sprintf("LOWER(HEX(%s))", $column); + } + } + + public function quoteBinary($binary) + { + if ($binary === '') { + return ''; + } + + if (is_array($binary)) { + return array_map([$this, 'quoteBinary'], $binary); + } + + if ($this->isPgsql()) { + return new Expr("'\\x" . bin2hex($binary) . "'"); + } + + return new Expr('0x' . bin2hex($binary)); + } + + public function isPgsql() + { + return $this->db() instanceof \Zend_Db_Adapter_Pdo_Pgsql; + } + + public function isMysql() + { + return $this->db() instanceof \Zend_Db_Adapter_Pdo_Mysql; + } + + public function wantBinaryValue($value) + { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + public function getChecksum($checksum) + { + return bin2hex($this->wantBinaryValue($checksum)); + } + + public function getShortChecksum($checksum) + { + if ($checksum === null) { + return null; + } + + return substr($this->getChecksum($checksum), 0, 7); + } +} diff --git a/library/Director/Web/Table/Dependency/DependencyInfoTable.php b/library/Director/Web/Table/Dependency/DependencyInfoTable.php new file mode 100644 index 0000000..28aa856 --- /dev/null +++ b/library/Director/Web/Table/Dependency/DependencyInfoTable.php @@ -0,0 +1,101 @@ +<?php + +namespace Icinga\Module\Director\Web\Table\Dependency; + +use Icinga\Application\Modules\Module; +use Icinga\Module\Director\Application\DependencyChecker; +use Icinga\Web\Url; + +class DependencyInfoTable +{ + protected $module; + + protected $checker; + + public function __construct(DependencyChecker $checker, Module $module) + { + $this->module = $module; + $this->checker = $checker; + } + + protected function linkToModule($name, $icon) + { + return Html::link( + Html::escape($name), + Html::webUrl('config/module', ['name' => $name]), + [ + 'class' => "icon-$icon" + ] + ); + } + + public function render() + { + $html = '<table class="common-table table-row-selectable"> +<thead> +<tr> + <th>' . Html::escape($this->translate('Module name')) . '</th> + <th>' . Html::escape($this->translate('Required')) . '</th> + <th>' . Html::escape($this->translate('Installed')) . '</th> +</tr> +</thead> +<tbody data-base-target="_next"> +'; + foreach ($this->checker->getDependencies($this->module) as $dependency) { + $name = $dependency->getName(); + $isLibrary = substr($name, 0, 11) === 'icinga-php-'; + $rowAttributes = $isLibrary ? ['data-base-target' => '_self'] : null; + if ($dependency->isSatisfied()) { + if ($dependency->isSatisfied()) { + $icon = 'ok'; + } else { + $icon = 'cancel'; + } + $link = $isLibrary ? $this->noLink($name, $icon) : $this->linkToModule($name, $icon); + $installed = $dependency->getInstalledVersion(); + } elseif ($dependency->isInstalled()) { + $installed = sprintf('%s (%s)', $dependency->getInstalledVersion(), $this->translate('disabled')); + $link = $this->linkToModule($name, 'cancel'); + } else { + $installed = $this->translate('missing'); + $repository = $isLibrary ? $name : "icingaweb2-module-$name"; + $link = sprintf( + '%s (%s)', + $this->noLink($name, 'cancel'), + Html::linkToGitHub(Html::escape($this->translate('more')), 'Icinga', $repository) + ); + } + + $html .= $this->htmlRow([ + $link, + Html::escape($dependency->getRequirement()), + Html::escape($installed) + ], $rowAttributes); + } + + return $html . '</tbody> +</table> +'; + } + + protected function noLink($label, $icon) + { + return Html::link(Html::escape($label), Url::fromRequest()->with('rnd', rand(1, 100000)), [ + 'class' => "icon-$icon" + ]); + } + + protected function translate($string) + { + return \mt('director', $string); + } + + protected function htmlRow(array $cols, $rowAttributes) + { + $content = ''; + foreach ($cols as $escapedContent) { + $content .= Html::tag('td', null, $escapedContent); + } + return Html::tag('tr', $rowAttributes, $content); + } +} diff --git a/library/Director/Web/Table/Dependency/Html.php b/library/Director/Web/Table/Dependency/Html.php new file mode 100644 index 0000000..092f799 --- /dev/null +++ b/library/Director/Web/Table/Dependency/Html.php @@ -0,0 +1,74 @@ +<?php + +namespace Icinga\Module\Director\Web\Table\Dependency; + +use Icinga\Web\Url; +use InvalidArgumentException; + +/** + * Minimal HTML helper, as we might be forced to run without ipl + */ +class Html +{ + public static function tag($tag, $attributes = [], $escapedContent = null) + { + $result = "<$tag"; + if (! empty($attributes)) { + foreach ($attributes as $name => $value) { + if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { + throw new InvalidArgumentException("Invalid attribute name: '$name'"); + } + + $result .= " $name=\"" . self::escapeAttributeValue($value) . '"'; + } + } + + return "$result>$escapedContent</$tag>"; + } + + public static function webUrl($path, $params) + { + return Url::fromPath($path, $params); + } + + public static function link($escapedLabel, $url, $attributes = []) + { + return static::tag('a', [ + 'href' => $url, + ] + $attributes, $escapedLabel); + } + + public static function linkToGitHub($escapedLabel, $namespace, $repository) + { + return static::link( + $escapedLabel, + 'https://github.com/' . urlencode($namespace) . '/' . urlencode($repository), + [ + 'target' => '_blank', + 'rel' => 'noreferrer', + 'class' => 'icon-forward' + ] + ); + } + + protected static function escapeAttributeValue($value) + { + $value = str_replace('"', '"', $value); + // Escape ambiguous ampersands + return preg_replace_callback('/&[0-9A-Z]+;/i', function ($match) { + $subject = $match[0]; + + if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { + // Ambiguous ampersand + return str_replace('&', '&', $subject); + } + + return $subject; + }, $value); + } + + public static function escape($any) + { + return htmlspecialchars($any); + } +} diff --git a/library/Director/Web/Table/DependencyTemplateUsageTable.php b/library/Director/Web/Table/DependencyTemplateUsageTable.php new file mode 100644 index 0000000..d7537c5 --- /dev/null +++ b/library/Director/Web/Table/DependencyTemplateUsageTable.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +class DependencyTemplateUsageTable extends TemplateUsageTable +{ + public function getTypes() + { + return [ + 'templates' => $this->translate('Templates'), + 'applyrules' => $this->translate('Apply Rules'), + ]; + } + + protected function getTypeSummaryDefinitions() + { + return [ + 'templates' => $this->getSummaryLine('template'), + 'applyrules' => $this->getSummaryLine('apply'), + ]; + } +} diff --git a/library/Director/Web/Table/DeploymentLogTable.php b/library/Director/Web/Table/DeploymentLogTable.php new file mode 100644 index 0000000..2d5cb94 --- /dev/null +++ b/library/Director/Web/Table/DeploymentLogTable.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Date\DateFormatter; + +class DeploymentLogTable extends ZfQueryBasedTable +{ + use DbHelper; + + protected $activeStageName; + + public function setActiveStageName($name) + { + $this->activeStageName = $name; + return $this; + } + + public function assemble() + { + $this->getAttributes()->add('class', 'deployment-log'); + } + + public function renderRow($row) + { + $this->splitByDay($row->start_time); + + $shortSum = $this->getShortChecksum($row->config_checksum); + $tr = $this::tr([ + $this::td(Link::create( + $shortSum === null ? $row->peer_identity : [$row->peer_identity, " ($shortSum)"], + 'director/deployment', + ['id' => $row->id] + )), + $this::td(DateFormatter::formatTime($row->start_time)) + ])->addAttributes(['class' => $this->getMyRowClasses($row)]); + + return $tr; + } + + protected function getMyRowClasses($row) + { + if ($row->startup_succeeded === 'y') { + $classes = ['succeeded']; + } elseif ($row->startup_succeeded === 'n') { + $classes = ['failed']; + } elseif ($row->stage_collected === null) { + $classes = ['pending']; + } elseif ($row->dump_succeeded === 'y') { + $classes = ['sent']; + } else { + // TODO: does this ever be stored? + $classes = ['notsent']; + } + + if ($this->activeStageName !== null + && $row->stage_name === $this->activeStageName + ) { + $classes[] = 'running'; + } + + return $classes; + } + + public function getColumns() + { + $columns = [ + 'id' => 'l.id', + 'peer_identity' => 'l.peer_identity', + 'start_time' => 'UNIX_TIMESTAMP(l.start_time)', + 'stage_collected' => 'l.stage_collected', + 'dump_succeeded' => 'l.dump_succeeded', + 'stage_name' => 'l.stage_name', + 'startup_succeeded' => 'l.startup_succeeded', + 'config_checksum' => 'l.config_checksum', + ]; + + return $columns; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + array('l' => 'director_deployment_log'), + $this->getColumns() + )->order('l.start_time DESC')->limit(100); + } +} diff --git a/library/Director/Web/Table/FilterableByUsage.php b/library/Director/Web/Table/FilterableByUsage.php new file mode 100644 index 0000000..5e8695f --- /dev/null +++ b/library/Director/Web/Table/FilterableByUsage.php @@ -0,0 +1,10 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +interface FilterableByUsage +{ + public function showOnlyUsed(); + + public function showOnlyUnUsed(); +} diff --git a/library/Director/Web/Table/GeneratedConfigFileTable.php b/library/Director/Web/Table/GeneratedConfigFileTable.php new file mode 100644 index 0000000..97f7091 --- /dev/null +++ b/library/Director/Web/Table/GeneratedConfigFileTable.php @@ -0,0 +1,120 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class GeneratedConfigFileTable extends ZfQueryBasedTable +{ + use DbHelper; + + protected $searchColumns = ['file_path']; + + protected $deploymentId; + + protected $activeFile; + + /** @var IcingaConfig */ + protected $config; + + public static function load(IcingaConfig $config, Db $db) + { + $table = new static($db); + $table->config = $config; + $table->getAttributes()->set('data-base-target', '_self'); + return $table; + } + + public function renderRow($row) + { + $counts = implode(' / ', [ + $row->cnt_object, + $row->cnt_template, + $row->cnt_apply + ]); + + $tr = $this::row([ + $this->getFileLink($row), + $counts, + $row->size + ]); + + if ($row->file_path === $this->activeFile) { + $tr->getAttributes()->add('class', 'active'); + } + + return $tr; + } + + public function setActiveFilename($filename) + { + $this->activeFile = $filename; + return $this; + } + + protected function getFileLink($row) + { + $params = [ + 'config_checksum' => $row->config_checksum, + 'file_path' => $row->file_path + ]; + + if ($this->deploymentId) { + $params['deployment_id'] = $this->deploymentId; + } + + return Link::create($row->file_path, 'director/config/file', $params); + } + + public function setDeploymentId($id) + { + if ($id) { + $this->deploymentId = (int) $id; + } + + return $this; + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('File'), + $this->translate('Object/Tpl/Apply'), + $this->translate('Size'), + ]; + } + + public function prepareQuery() + { + $columns = [ + 'file_path' => 'cf.file_path', + 'size' => 'LENGTH(f.content)', + 'cnt_object' => 'f.cnt_object', + 'cnt_template' => 'f.cnt_template', + 'cnt_apply' => 'f.cnt_apply', + 'cnt_all' => "f.cnt_object || ' / ' || f.cnt_template || ' / ' || f.cnt_apply", + 'checksum' => 'LOWER(HEX(f.checksum))', + 'config_checksum' => 'LOWER(HEX(cf.config_checksum))', + ]; + + if ($this->isPgsql()) { + $columns['checksum'] = "LOWER(ENCODE(f.checksum, 'hex'))"; + $columns['config_checksum'] = "LOWER(ENCODE(cf.config_checksum, 'hex'))"; + } + + return $this->db()->select()->from( + ['cf' => 'director_generated_config_file'], + $columns + )->join( + ['f' => 'director_generated_file'], + 'cf.file_checksum = f.checksum', + [] + )->where( + 'config_checksum = ?', + $this->quoteBinary($this->config->getChecksum()) + )->order('cf.file_path ASC'); + } +} diff --git a/library/Director/Web/Table/GroupMemberTable.php b/library/Director/Web/Table/GroupMemberTable.php new file mode 100644 index 0000000..b0814ad --- /dev/null +++ b/library/Director/Web/Table/GroupMemberTable.php @@ -0,0 +1,201 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Table\Extension\MultiSelect; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\IcingaConfig\AssignRenderer; +use Icinga\Module\Director\Objects\IcingaObjectGroup; +use Exception; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; + +class GroupMemberTable extends ZfQueryBasedTable +{ + use MultiSelect; + + protected $searchColumns = [ + 'o.object_name', + // membership_type + ]; + + protected $type; + + /** @var IcingaObjectGroup */ + protected $group; + + /** + * @param $type + * @param Db $db + * @return static + */ + public static function create($type, Db $db) + { + $class = __NAMESPACE__ . '\\GroupMemberTable' . ucfirst($type); + if (! class_exists($class)) { + $class = __CLASS__; + } + + /** @var static $table */ + $table = new $class($db); + $table->type = $type; + return $table; + } + public function assemble() + { + if ($this->type === 'host') { + $this->enableMultiSelect( + 'director/hosts/edit', + 'director/hosts', + ['name'] + ); + } + } + + public function setGroup(IcingaObjectGroup $group) + { + $this->group = $group; + return $this; + } + + public function getType() + { + return $this->type; + } + + public function getColumnsToBeRendered() + { + if ($this->group === null) { + return [ + $this->translate('Group'), + $this->translate('Member'), + $this->translate('via') + ]; + } else { + return [ + $this->translate('Member'), + $this->translate('via') + ]; + } + } + + public function renderRow($row) + { + $type = $this->getType(); + if ($row->object_type === 'apply') { + $params = [ + 'id' => $row->id + ]; + } elseif (isset($row->host_id)) { + // I would prefer to see host=<name> and set=<name>, but joining + // them here is pointless. We should use DeferredHtml for these, + // remember hosts/sets we need and fetch them in a single query at + // rendering time. For now, this works fine - just... the URLs are + // not so nice + $params = [ + 'name' => $row->object_name, + 'host_id' => $row->host_id + ]; + } elseif (isset($row->service_set_id)) { + $params = [ + 'name' => $row->object_name, + 'set_id' => $row->service_set_id + ]; + } else { + $params = [ + 'name' => $row->object_name + ]; + } + + $url = Url::fromPath("director/${type}", $params); + + $tr = $this::tr(); + + if ($this->group === null) { + $tr->add($this::td($row->group_name)); + } + $link = Link::create($row->object_name, $url); + if ($row->object_type === 'apply') { + $link = [ + $link, + ' (where ', + $this->renderApplyFilter($row->assign_filter), + ')' + ]; + } + + $tr->add([ + $this::td($link), + $this::td($row->membership_type) + ]); + + return $tr; + } + + protected function renderApplyFilter($assignFilter) + { + try { + $string = AssignRenderer::forFilter( + Filter::fromQueryString($assignFilter) + )->renderAssign(); + // Do not prefix it + $string = preg_replace('/^assign where /', '', $string); + } catch (Exception $e) { + // ignore errors in filter rendering + $string = 'Error in Filter rendering: ' . $e->getMessage(); + } + + return $string; + } + + protected function prepareQuery() + { + // select h.object_name, hg.object_name, + // CASE WHEN hgh.host_id IS NULL THEN 'apply' ELSE 'direct' END AS assi + // from icinga_hostgroup_host_resolved hgr join icinga_host h on h.id = hgr.host_id + // join icinga_hostgroup hg on hgr.hostgroup_id = hg.id + // left join icinga_hostgroup_host hgh on hgh.host_id = h.id and hgh.hostgroup_id = hg.id; + + $type = $this->getType(); + $columns = [ + 'o.id', + 'o.object_type', + 'o.object_name', + 'membership_type' => "CASE WHEN go.${type}_id IS NULL THEN 'apply' ELSE 'direct' END" + ]; + + if ($this->group === null) { + $columns = ['group_name' => 'g.object_name'] + $columns; + } + if ($type === 'service') { + $columns[] = 'o.assign_filter'; + $columns[] = 'o.host_id'; + $columns[] = 'o.service_set_id'; + } + + $query = $this->db()->select()->from( + ['gro' => "icinga_${type}group_${type}_resolved"], + $columns + )->join( + ['o' => "icinga_${type}"], + "o.id = gro.${type}_id", + [] + )->join( + ['g' => "icinga_${type}group"], + "gro.${type}group_id = g.id", + [] + )->joinLeft( + ['go' => "icinga_${type}group_${type}"], + "go.${type}_id = o.id AND go.${type}group_id = g.id", + [] + )->order('o.object_name'); + + if ($this->group !== null) { + $query->where('g.id = ?', $this->group->get('id')); + } + + return $query; + } +} diff --git a/library/Director/Web/Table/HostTemplateUsageTable.php b/library/Director/Web/Table/HostTemplateUsageTable.php new file mode 100644 index 0000000..2d1ee2f --- /dev/null +++ b/library/Director/Web/Table/HostTemplateUsageTable.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +class HostTemplateUsageTable extends TemplateUsageTable +{ + public function getTypes() + { + return [ + 'templates' => $this->translate('Templates'), + 'objects' => $this->translate('Objects'), + ]; + } + + protected function getTypeSummaryDefinitions() + { + return [ + 'templates' => $this->getSummaryLine('template'), + 'objects' => $this->getSummaryLine('object'), + ]; + } +} diff --git a/library/Director/Web/Table/IcingaAppliedServiceTable.php b/library/Director/Web/Table/IcingaAppliedServiceTable.php new file mode 100644 index 0000000..b669296 --- /dev/null +++ b/library/Director/Web/Table/IcingaAppliedServiceTable.php @@ -0,0 +1,49 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\IcingaService; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class IcingaAppliedServiceTable extends ZfQueryBasedTable +{ + protected $service; + + protected $searchColumns = array( + 'service', + ); + + public function setService(IcingaService $service) + { + $this->service = $service; + return $this; + } + + public function renderRow($row) + { + return $this::row([ + new Link($row->service, 'director/service', ['id' => $row->id]) + ]); + } + + public function getColumnsToBeRendered() + { + return [$this->translate('Servicename')]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + array('s' => 'icinga_service'), + array() + )->joinLeft( + array('si' => 'icinga_service_inheritance'), + 's.id = si.service_id', + array() + )->where( + 'si.parent_service_id = ?', + $this->service->id + )->where('s.object_type = ?', 'apply'); + } +} diff --git a/library/Director/Web/Table/IcingaCommandArgumentTable.php b/library/Director/Web/Table/IcingaCommandArgumentTable.php new file mode 100644 index 0000000..37cbc78 --- /dev/null +++ b/library/Director/Web/Table/IcingaCommandArgumentTable.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\Data\Json; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchModificationStore; +use Icinga\Module\Director\Objects\IcingaCommand; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class IcingaCommandArgumentTable extends ZfQueryBasedTable +{ + /** @var IcingaCommand */ + protected $command; + + /** @var Branch */ + protected $branch; + + protected $searchColumns = [ + 'ca.argument_name', + 'ca.argument_value', + ]; + + public function __construct(IcingaCommand $command, Branch $branch) + { + $this->command = $command; + $this->branch = $branch; + parent::__construct($command->getConnection()); + $this->getAttributes()->set('data-base-target', '_self'); + } + + public function renderRow($row) + { + return $this::row([ + Link::create($row->argument_name, 'director/command/arguments', [ + 'argument' => $row->argument_name, + 'name' => $this->command->getObjectName() + ]), + $row->argument_value + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Argument'), + $this->translate('Value'), + ]; + } + + public function prepareQuery() + { + $db = $this->db(); + if ($this->branch->isBranch()) { + return (new ArrayDatasource((array) $this->command->arguments()->toPlainObject()))->select(); + /** @var Db $connection */ + $connection = $this->connection(); + $store = new BranchModificationStore($connection, 'command'); + $modification = $store->loadOptionalModificationByName( + $this->command->getObjectName(), + $this->branch->getUuid() + ); + if ($modification) { + $props = $modification->getProperties()->jsonSerialize(); + if (isset($props->arguments)) { + return new ArrayDatasource((array) $this->command->arguments()->toPlainObject()); + } + } + } + $id = $this->command->get('id'); + if ($id === null) { + return new ArrayDatasource([]); + } + return $this->db()->select()->from( + ['ca' => 'icinga_command_argument'], + [ + 'id' => 'ca.id', + 'argument_name' => "COALESCE(ca.argument_name, '(none)')", + 'argument_value' => 'ca.argument_value', + ] + )->where( + 'ca.command_id = ?', + $id + )->order('ca.sort_order')->order('ca.argument_name')->limit(100); + } +} diff --git a/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php new file mode 100644 index 0000000..0d2f8e8 --- /dev/null +++ b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php @@ -0,0 +1,117 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\CustomVariable\CustomVariableDictionary; +use Icinga\Module\Director\Objects\IcingaHost; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable; + +class IcingaHostAppliedForServiceTable extends SimpleQueryBasedTable +{ + protected $title; + + protected $host; + + /** @var CustomVariableDictionary */ + protected $cv; + + protected $searchColumns = [ + 'service', + ]; + + /** @var bool */ + protected $readonly = false; + + /** @var string|null */ + protected $highlightedService; + + /** + * @param IcingaHost $host + * @param CustomVariableDictionary $dict + * @return static + */ + public static function load(IcingaHost $host, CustomVariableDictionary $dict) + { + $table = (new static())->setHost($host)->setDictionary($dict); + $table->getAttributes()->set('data-base-target', '_self'); + return $table; + } + + public function setDictionary(CustomVariableDictionary $dict) + { + $this->cv = $dict; + return $this; + } + + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + /** + * Show no related links + * + * @param bool $readonly + * @return $this + */ + public function setReadonly($readonly = true) + { + $this->readonly = (bool) $readonly; + + return $this; + } + + public function highlightService($service) + { + $this->highlightedService = $service; + + return $this; + } + + public function renderRow($row) + { + if ($this->readonly) { + if ($this->highlightedService === $row->service) { + $link = Html::tag('span', ['class' => 'icon-right-big'], $row->service); + } else { + $link = $row->service; + } + } else { + $link = Link::create($row->service, 'director/host/appliedservice', [ + 'name' => $this->host->object_name, + 'service' => $row->service, + ]); + } + + return $this::row([$link]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->title ?: $this->translate('Service name'), + ]; + } + + public function prepareQuery() + { + $data = []; + foreach ($this->cv->getValue() as $key => $var) { + $data[] = (object) array( + 'service' => $key, + ); + } + + return (new ArrayDatasource($data))->select(); + } +} diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php new file mode 100644 index 0000000..415903b --- /dev/null +++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php @@ -0,0 +1,207 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\IcingaConfig\AssignRenderer; +use Icinga\Module\Director\Objects\HostApplyMatches; +use Icinga\Module\Director\Objects\IcingaHost; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable; + +class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable +{ + protected $title; + + /** @var IcingaHost */ + protected $host; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var bool */ + protected $readonly = false; + + /** @var string|null */ + protected $highlightedService; + + private $allApplyRules; + + /** + * @param IcingaHost $host + * @return static + */ + public static function load(IcingaHost $host) + { + $table = (new static())->setHost($host); + $table->getAttributes()->set('data-base-target', '_self'); + return $table; + } + + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + public function getColumnsToBeRendered() + { + return [$this->title]; + } + + public function setHost(IcingaHost $host) + { + $this->host = $host; + $this->db = $host->getDb(); + return $this; + } + + /** + * Show no related links + * + * @param bool $readonly + * @return $this + */ + public function setReadonly($readonly = true) + { + $this->readonly = (bool) $readonly; + + return $this; + } + + public function highlightService($service) + { + $this->highlightedService = $service; + + return $this; + } + + public function renderRow($row) + { + $classes = []; + if ($row->blacklisted === 'y') { + $classes[] = 'strike-links'; + } + if ($row->disabled === 'y') { + $classes[] = 'disabled'; + } + + $attributes = empty($classes) ? null : ['class' => $classes]; + + if ($this->readonly) { + if ($this->highlightedService === $row->name) { + $link = Html::tag('a', ['class' => 'icon-right-big'], $row->name); + } else { + $link = Html::tag('a', $row->name); + } + } else { + $applyFor = ''; + if (! empty($row->apply_for)) { + $applyFor = sprintf('(apply for %s) ', $row->apply_for); + } + + $link = Link::create(sprintf( + $this->translate('%s %s(%s)'), + $row->name, + $applyFor, + $this->renderApplyFilter($row->filter) + ), 'director/host/appliedservice', [ + 'name' => $this->host->getObjectName(), + 'service_id' => $row->id, + ]); + } + + return $this::row([$link], $attributes); + } + + /** + * @param Filter $assignFilter + * + * @return string + */ + protected function renderApplyFilter(Filter $assignFilter) + { + try { + $string = AssignRenderer::forFilter($assignFilter)->renderAssign(); + } catch (IcingaException $e) { + $string = 'Error in Filter rendering: ' . $e->getMessage(); + } + + return $string; + } + + /** + * @return \Icinga\Data\SimpleQuery + */ + public function prepareQuery() + { + $services = []; + $matcher = HostApplyMatches::prepare($this->host); + foreach ($this->getAllApplyRules() as $rule) { + if ($matcher->matchesFilter($rule->filter)) { + $services[] = $rule; + } + } + + $ds = new ArrayDatasource($services); + return $ds->select()->columns([ + 'id' => 'id', + 'uuid' => 'uuid', + 'name' => 'name', + 'filter' => 'filter', + 'disabled' => 'disabled', + 'blacklisted' => 'blacklisted', + 'assign_filter' => 'assign_filter', + 'apply_for' => 'apply_for', + ]); + } + + /*** + * @return array + */ + protected function getAllApplyRules() + { + if ($this->allApplyRules === null) { + $this->allApplyRules = $this->fetchAllApplyRules(); + foreach ($this->allApplyRules as $rule) { + $rule->filter = Filter::fromQueryString($rule->assign_filter); + } + } + + return $this->allApplyRules; + } + + /** + * @return array + */ + protected function fetchAllApplyRules() + { + $db = $this->db; + $hostId = $this->host->get('id'); + $query = $db->select()->from( + ['s' => 'icinga_service'], + [ + 'id' => 's.id', + 'uuid' => 's.uuid', + 'name' => 's.object_name', + 'assign_filter' => 's.assign_filter', + 'apply_for' => 's.apply_for', + 'disabled' => 's.disabled', + 'blacklisted' => $hostId ? "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END" : "('n')", + ] + )->where('object_type = ? AND assign_filter IS NOT NULL', 'apply') + ->order('s.object_name'); + if ($hostId) { + $query->joinLeft( + ['hsb' => 'icinga_host_service_blacklist'], + $db->quoteInto('s.id = hsb.service_id AND hsb.host_id = ?', $hostId), + [] + ); + } + + return $db->fetchAll($query); + } +} diff --git a/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php new file mode 100644 index 0000000..8d225bf --- /dev/null +++ b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php @@ -0,0 +1,71 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\QueryBasedTable; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Data\Filter\Filter; +use Icinga\Data\SimpleQuery; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Resolver\IcingaHostObjectResolver; + +class IcingaHostsMatchingFilterTable extends QueryBasedTable +{ + protected $searchColumns = [ + 'object_name', + ]; + + /** @var ArrayDatasource */ + protected $dataSource; + + public static function load(Filter $filter, Db $db) + { + $table = new static(); + $table->dataSource = new ArrayDatasource( + (new IcingaHostObjectResolver($db->getDbAdapter())) + ->fetchObjectsMatchingFilter($filter) + ); + + return $table; + } + + public function renderRow($row) + { + return $this::row([ + Link::create( + $row->object_name, + 'director/host', + ['name' => $row->object_name] + ) + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Hostname'), + ]; + } + + protected function getPaginationAdapter() + { + return new SimpleQueryPaginationAdapter($this->getQuery()); + } + + public function getQuery() + { + return $this->prepareQuery(); + } + + protected function fetchQueryRows() + { + return $this->dataSource->fetchAll($this->getQuery()); + } + + protected function prepareQuery() + { + return new SimpleQuery($this->dataSource, ['object_name']); + } +} diff --git a/library/Director/Web/Table/IcingaObjectDatafieldTable.php b/library/Director/Web/Table/IcingaObjectDatafieldTable.php new file mode 100644 index 0000000..f97692e --- /dev/null +++ b/library/Director/Web/Table/IcingaObjectDatafieldTable.php @@ -0,0 +1,87 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader; +use Icinga\Web\Url; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable; + +class IcingaObjectDatafieldTable extends SimpleQueryBasedTable +{ + protected $object; + + /** @var int */ + protected $objectId; + + public function __construct(IcingaObject $object) + { + $this->object = $object; + $this->objectId = (int) $object->id; + return $this; + } + + protected $searchColumns = array( + 'varname', + 'caption' + ); + + public function getColumns() + { + return array( + 'object_id', + 'var_filter', + 'is_required', + 'id', + 'varname', + 'caption', + 'description', + 'datatype', + 'format', + ); + } + + public function getColumnsToBeRendered() + { + return array( + 'caption' => $this->translate('Label'), + 'varname' => $this->translate('Field name'), + 'is_required' => $this->translate('Mandatory'), + ); + } + + public function renderRow($row) + { + $definedOnThis = (int) $row->object_id === $this->objectId; + if ($definedOnThis) { + $caption = new Link( + $row->caption, + Url::fromRequest()->with('field_id', $row->id) + ); + } else { + $caption = $row->caption; + } + + $row = $this::row([ + $caption, + $row->varname, + $row->is_required + ]); + + if (! $definedOnThis) { + $row->getAttributes()->add('class', 'disabled'); + } + + return $row; + } + + public function prepareQuery() + { + $loader = new IcingaObjectFieldLoader($this->object); + $fields = $loader->fetchFieldDetailsForObject($this->object); + $ds = new ArrayDatasource($fields); + return $ds->select(); + } +} diff --git a/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php new file mode 100644 index 0000000..cd8f8b1 --- /dev/null +++ b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\IcingaScheduledDowntime; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class IcingaScheduledDowntimeRangeTable extends ZfQueryBasedTable +{ + /** @var IcingaScheduledDowntime */ + protected $downtime; + + protected $searchColumns = [ + 'range_key', + 'range_value', + ]; + + /** + * @param IcingaScheduledDowntime $downtime + * @return static + */ + public static function load(IcingaScheduledDowntime $downtime) + { + $table = new static($downtime->getConnection()); + $table->downtime = $downtime; + $table->getAttributes()->set('data-base-target', '_self'); + + return $table; + } + + public function renderRow($row) + { + return $this::row([ + Link::create( + $row->range_key, + 'director/scheduled-downtime/ranges', + [ + 'name' => $this->downtime->getObjectName(), + 'range' => $row->range_key, + 'range_type' => 'include' + ] + ), + $row->range_value + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Day(s)'), + $this->translate('Timeperiods'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['r' => 'icinga_scheduled_downtime_range'], + [ + 'scheduled_downtime_id' => 'r.scheduled_downtime_id', + 'range_key' => 'r.range_key', + 'range_value' => 'r.range_value', + ] + )->where('r.scheduled_downtime_id = ?', $this->downtime->id); + } +} diff --git a/library/Director/Web/Table/IcingaServiceSetHostTable.php b/library/Director/Web/Table/IcingaServiceSetHostTable.php new file mode 100644 index 0000000..9fc3c61 --- /dev/null +++ b/library/Director/Web/Table/IcingaServiceSetHostTable.php @@ -0,0 +1,64 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\IcingaServiceSet; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class IcingaServiceSetHostTable extends ZfQueryBasedTable +{ + protected $set; + + protected $searchColumns = array( + 'host', + ); + + public static function load(IcingaServiceSet $set) + { + $table = new static($set->getConnection()); + $table->set = $set; + return $table; + } + + public function renderRow($row) + { + return $this::row([ + Link::create( + $row->host, + 'director/host', + ['name' => $row->host] + ) + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Hostname'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['h' => 'icinga_host'], + [ + 'id' => 'h.id', + 'host' => 'h.object_name', + 'object_type' => 'h.object_type', + ] + )->joinLeft( + ['ssh' => 'icinga_service_set'], + 'ssh.host_id = h.id', + [] + )->joinLeft( + ['ssih' => 'icinga_service_set_inheritance'], + 'ssih.service_set_id = ssh.id', + [] + )->where( + 'ssih.parent_service_set_id = ?', + $this->set->id + )->order('h.object_name'); + } +} diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php new file mode 100644 index 0000000..c205e66 --- /dev/null +++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php @@ -0,0 +1,259 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Data\Db\ServiceSetQueryBuilder; +use Icinga\Module\Director\Db; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use Icinga\Module\Director\Forms\RemoveLinkForm; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; + +class IcingaServiceSetServiceTable extends ZfQueryBasedTable +{ + use TableWithBranchSupport; + + /** @var IcingaServiceSet */ + protected $set; + + protected $title; + + /** @var IcingaHost */ + protected $host; + + /** @var IcingaHost */ + protected $affectedHost; + + protected $searchColumns = [ + 'service', + ]; + + /** @var bool */ + protected $readonly = false; + + /** @var string|null */ + protected $highlightedService; + + /** + * @param IcingaServiceSet $set + * @return static + */ + public static function load(IcingaServiceSet $set) + { + $table = new static($set->getConnection()); + $table->set = $set; + $table->getAttributes()->set('data-base-target', '_self'); + return $table; + } + + /** + * @param string $title + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * @param IcingaHost $host + * @return $this + */ + public function setHost(IcingaHost $host) + { + $this->host = $host; + return $this; + } + + /** + * @param IcingaHost $host + * @return $this + */ + public function setAffectedHost(IcingaHost $host) + { + $this->affectedHost = $host; + return $this; + } + + /** + * Show no related links + * + * @param bool $readonly + * @return $this + */ + public function setReadonly($readonly = true) + { + $this->readonly = (bool) $readonly; + + return $this; + } + + public function highlightService($service) + { + $this->highlightedService = $service; + + return $this; + } + + /** + * @param $row + * @return BaseHtmlElement + */ + protected function getServiceLink($row) + { + if ($this->readonly) { + if ($this->highlightedService === $row->service) { + return Html::tag('span', ['class' => 'ro-service icon-right-big'], $row->service); + } + + return Html::tag('span', ['class' => 'ro-service'], $row->service); + } + + if ($this->affectedHost) { + $params = [ + 'uuid' => $this->affectedHost->getUniqueId()->toString(), + 'service' => $row->service, + 'set' => $row->service_set + ]; + $url = 'director/host/servicesetservice'; + } else { + $params = [ + 'name' => $row->service, + 'set' => $row->service_set + ]; + $url = 'director/service'; + } + + return Link::create( + $row->service, + $url, + $params + ); + } + + public function renderRow($row) + { + $tr = $this::row([ + $this->getServiceLink($row) + ]); + $classes = $this->getRowClasses($row); + if ($row->disabled === 'y') { + $classes[] = 'disabled'; + } + if ($row->blacklisted === 'y') { + $classes[] = 'strike-links'; + } + if (! empty($classes)) { + $tr->getAttributes()->add('class', $classes); + } + + return $tr; + } + + protected function getRowClasses($row) + { + if ($row->branch_uuid !== null) { + return ['branch_modified']; + } + return []; + } + + protected function getTitle() + { + return $this->title ?: $this->translate('Servicename'); + } + + protected function renderTitleColumns() + { + if (! $this->host || ! $this->affectedHost) { + return Html::tag('th', $this->getTitle()); + } + + if ($this->readonly) { + $link = $this->createFakeRemoveLinkForReadonlyView(); + } elseif ($this->affectedHost->get('id') !== $this->host->get('id')) { + $link = $this->linkToHost($this->host); + } else { + $link = $this->createRemoveLinkForm(); + } + + return $this::th([$this->getTitle(), $link]); + } + + /** + * @return \Zend_Db_Select + * @throws \Zend_Db_Select_Exception + */ + public function prepareQuery() + { + $connection = $this->connection(); + assert($connection instanceof Db); + $builder = new ServiceSetQueryBuilder($connection, $this->branchUuid); + return $builder->selectServicesForSet($this->set)->limit(100); + } + + protected function createFakeRemoveLinkForReadonlyView() + { + return Html::tag('span', [ + 'class' => 'icon-paste', + 'style' => 'float: right; font-weight: normal', + ], $this->host->getObjectName()); + } + + protected function linkToHost(IcingaHost $host) + { + $hostname = $host->getObjectName(); + return Link::create($hostname, 'director/host/services', ['name' => $hostname], [ + 'class' => 'icon-paste', + 'style' => 'float: right; font-weight: normal', + 'data-base-target' => '_next', + 'title' => sprintf( + $this->translate('This set has been inherited from %s'), + $hostname + ) + ]); + } + + protected function createRemoveLinkForm() + { + $deleteLink = new RemoveLinkForm( + $this->translate('Remove'), + sprintf( + $this->translate('Remove "%s" from this host'), + $this->getTitle() + ), + Url::fromPath('director/host/services', [ + 'name' => $this->host->getObjectName() + ]), + ['title' => $this->getTitle()] + ); + $deleteLink->runOnSuccess(function () { + $conn = $this->set->getConnection(); + $db = $conn->getDbAdapter(); + $query = $db->select()->from(['ss' => 'icinga_service_set'], 'ss.id') + ->join(['ssih' => 'icinga_service_set_inheritance'], 'ssih.service_set_id = ss.id', []) + ->where('ssih.parent_service_set_id = ?', $this->set->get('id')) + ->where('ss.host_id = ?', $this->host->get('id')); + IcingaServiceSet::loadWithAutoIncId( + $db->fetchOne($query), + $conn + )->delete(); + }); + $deleteLink->handleRequest(); + return $deleteLink; + } + + public function removeQueryLimit() + { + $query = $this->getQuery(); + $query->reset($query::LIMIT_OFFSET); + $query->reset($query::LIMIT_COUNT); + + return $this; + } +} diff --git a/library/Director/Web/Table/IcingaTimePeriodRangeTable.php b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php new file mode 100644 index 0000000..5870e67 --- /dev/null +++ b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php @@ -0,0 +1,61 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\IcingaTimePeriod; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class IcingaTimePeriodRangeTable extends ZfQueryBasedTable +{ + protected $period; + + protected $searchColumns = array( + 'range_key', + 'range_value', + ); + + public static function load(IcingaTimePeriod $period) + { + $table = new static($period->getConnection()); + $table->period = $period; + $table->getAttributes()->set('data-base-target', '_self'); + return $table; + } + + public function renderRow($row) + { + return $this::row([ + Link::create( + $row->range_key, + 'director/timeperiod/ranges', + array( + 'name' => $this->period->object_name, + 'range' => $row->range_key, + 'range_type' => 'include' + ) + ), + $row->range_value + ]); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Day(s)'), + $this->translate('Timeperiods'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['r' => 'icinga_timeperiod_range'], + [ + 'timeperiod_id' => 'r.timeperiod_id', + 'range_key' => 'r.range_key', + 'range_value' => 'r.range_value', + ] + )->where('r.timeperiod_id = ?', $this->period->id); + } +} diff --git a/library/Director/Web/Table/ImportedrowsTable.php b/library/Director/Web/Table/ImportedrowsTable.php new file mode 100644 index 0000000..d5c9811 --- /dev/null +++ b/library/Director/Web/Table/ImportedrowsTable.php @@ -0,0 +1,103 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\Html; +use ipl\Html\ValidHtml; +use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\Objects\ImportRun; +use Icinga\Module\Director\PlainObjectRenderer; + +class ImportedrowsTable extends SimpleQueryBasedTable +{ + protected $columns; + + /** @var ImportRun */ + protected $importRun; + + protected $keyColumn; + + public static function load(ImportRun $run) + { + $table = new static(); + $table->setImportRun($run); + return $table; + } + + public function setImportRun(ImportRun $run) + { + $this->importRun = $run; + return $this; + } + + public function setColumns($columns) + { + $this->columns = $columns; + return $this; + } + + protected function getKeyColumn() + { + if ($this->keyColumn === null) { + $this->keyColumn = $this->importRun->importSource()->get('key_column'); + } + + return $this->keyColumn; + } + + public function getColumns() + { + if ($this->columns === null) { + $cols = $this->importRun->listColumnNames(); + + $keyColumn = $this->getKeyColumn(); + if ($keyColumn !== null && ($pos = array_search($keyColumn, $cols)) !== false) { + unset($cols[$pos]); + array_unshift($cols, $keyColumn); + } + } else { + $cols = $this->columns; + } + + return array_combine($cols, $cols); + } + + public function renderRow($row) + { + // Find a better place! + if ($row === null) { + return null; + } + $tr = $this::tr(); + + foreach ($this->getColumnsToBeRendered() as $column) { + $td = $this::td(); + if (property_exists($row, $column)) { + if (is_string($row->$column) || $row->$column instanceof ValidHtml) { + $td->setContent($row->$column); + } else { + $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column)); + $td->setContent($html); + } + } + $tr->add($td); + } + + return $tr; + } + + public function getColumnsToBeRendered() + { + return $this->getColumns(); + } + + public function prepareQuery() + { + $ds = new ArrayDatasource( + $this->importRun->fetchRows($this->columns) + ); + + return $ds->select()->order($this->getKeyColumn()); + } +} diff --git a/library/Director/Web/Table/ImportrunTable.php b/library/Director/Web/Table/ImportrunTable.php new file mode 100644 index 0000000..e6c8a38 --- /dev/null +++ b/library/Director/Web/Table/ImportrunTable.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\ImportSource; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class ImportrunTable extends ZfQueryBasedTable +{ + use DbHelper; + + /** @var ImportSource */ + protected $source; + + protected $searchColumns = [ + 'source_name', + ]; + + public static function load(ImportSource $source) + { + $table = new static($source->getConnection()); + $table->source = $source; + return $table; + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Source name'), + $this->translate('Timestamp'), + $this->translate('Imported rows'), + ]; + } + + public function renderRow($row) + { + return $this::row([ + Link::create( + $row->source_name, + 'director/importrun', + ['id' => $row->id] + ), + $row->start_time, + $row->cnt_rows + ]); + } + + public function prepareQuery() + { + $db = $this->db(); + $columns = array( + 'id' => 'r.id', + 'source_id' => 's.id', + 'source_name' => 's.source_name', + 'start_time' => 'r.start_time', + 'rowset' => 'LOWER(HEX(rs.checksum))', + 'cnt_rows' => 'COUNT(rsr.row_checksum)', + ); + + if ($this->isPgsql()) { + $columns['rowset'] = "LOWER(ENCODE(rs.checksum, 'hex'))"; + } + + // TODO: Store row count to rowset + $query = $db->select()->from( + ['s' => 'import_source'], + $columns + )->join( + ['r' => 'import_run'], + 'r.source_id = s.id', + [] + )->joinLeft( + ['rs' => 'imported_rowset'], + 'rs.checksum = r.rowset_checksum', + [] + )->joinLeft( + ['rsr' => 'imported_rowset_row'], + 'rs.checksum = rsr.rowset_checksum', + [] + )->group('r.id')->group('s.id')->group('rs.checksum') + ->order('r.start_time DESC'); + + if ($this->source) { + $query->where('r.source_id = ?', $this->source->get('id')); + } + + return $query; + } +} diff --git a/library/Director/Web/Table/ImportsourceHookTable.php b/library/Director/Web/Table/ImportsourceHookTable.php new file mode 100644 index 0000000..5ddb6f3 --- /dev/null +++ b/library/Director/Web/Table/ImportsourceHookTable.php @@ -0,0 +1,107 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use ipl\Html\ValidHtml; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Import\SyncUtils; +use Icinga\Module\Director\Objects\ImportSource; +use Icinga\Module\Director\PlainObjectRenderer; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable; + +class ImportsourceHookTable extends SimpleQueryBasedTable +{ + /** @var ImportSource */ + protected $source; + + protected $columnCache; + + /** @var ImportSourceHook */ + protected $sourceHook; + + protected function assemble() + { + $this->getAttributes()->add('class', 'raw-data-table collapsed'); + } + + public function getColumns() + { + if ($this->columnCache === null) { + $this->columnCache = SyncUtils::getRootVariables(array_merge( + $this->sourceHook()->listColumns(), + $this->source->listModifierTargetProperties() + )); + + sort($this->columnCache); + + // prioritize key column + $keyColumn = $this->source->get('key_column'); + if ($keyColumn !== null && ($pos = array_search($keyColumn, $this->columnCache)) !== false) { + unset($this->columnCache[$pos]); + array_unshift($this->columnCache, $keyColumn); + } + } + + return $this->columnCache; + } + + public function setImportSource(ImportSource $source) + { + $this->source = $source; + return $this; + } + + public function getColumnsToBeRendered() + { + return $this->getColumns(); + } + + public function renderRow($row) + { + // Find a better place! + if ($row === null) { + return null; + } + if (\is_array($row)) { + $row = (object) $row; + } + $tr = $this::tr(); + + foreach ($this->getColumnsToBeRendered() as $column) { + $td = $this::td(); + if (\property_exists($row, $column)) { + if (\is_string($row->$column) || $row->$column instanceof ValidHtml) { + $td->setContent($row->$column); + } else { + $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column)); + $td->setContent($html); + } + } + $tr->add($td); + } + + return $tr; + } + + protected function sourceHook() + { + if ($this->sourceHook === null) { + $this->sourceHook = ImportSourceHook::forImportSource( + $this->source + ); + } + + return $this->sourceHook; + } + + public function prepareQuery() + { + $data = $this->sourceHook()->fetchData(); + $this->source->applyModifiers($data); + + $ds = new ArrayDatasource($data); + return $ds->select(); + } +} diff --git a/library/Director/Web/Table/ImportsourceTable.php b/library/Director/Web/Table/ImportsourceTable.php new file mode 100644 index 0000000..1a93ef5 --- /dev/null +++ b/library/Director/Web/Table/ImportsourceTable.php @@ -0,0 +1,63 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class ImportsourceTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'source_name', + 'description', + ]; + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Source name'), + ]; + } + + protected function assemble() + { + $this->getAttributes()->add('class', 'syncstate'); + parent::assemble(); + } + + public function renderRow($row) + { + $caption = [Link::create( + $row->source_name, + 'director/importsource', + ['id' => $row->id] + )]; + if ($row->description !== null) { + $caption[] = ': ' . $row->description; + } + + if ($row->import_state === 'failing' && $row->last_error_message) { + $caption[] = ' (' . $row->last_error_message . ')'; + } + + $tr = $this::row([$caption]); + $tr->getAttributes()->add('class', $row->import_state); + + return $tr; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['s' => 'import_source'], + [ + 'id' => 's.id', + 'source_name' => 's.source_name', + 'provider_class' => 's.provider_class', + 'import_state' => 's.import_state', + 'last_error_message' => 's.last_error_message', + 'description' => 's.description', + ] + )->order('source_name ASC'); + } +} diff --git a/library/Director/Web/Table/JobTable.php b/library/Director/Web/Table/JobTable.php new file mode 100644 index 0000000..81ba07b --- /dev/null +++ b/library/Director/Web/Table/JobTable.php @@ -0,0 +1,82 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class JobTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'job_name', + ]; + + protected function assemble() + { + $this->getAttributes()->add('class', 'jobs'); + parent::assemble(); + } + + public function renderRow($row) + { + $caption = [Link::create( + $row->job_name, + 'director/job', + ['id' => $row->id] + )]; + + if ($row->last_attempt_succeeded === 'n' && $row->last_error_message) { + $caption[] = ' (' . $row->last_error_message . ')'; + } + + $tr = $this::row([$caption]); + $tr->getAttributes()->add('class', $this->getJobClasses($row)); + + return $tr; + } + + protected function getJobClasses($row) + { + if ($row->unixts_last_attempt === null) { + return 'pending'; + } + + if ($row->unixts_last_attempt + $row->run_interval < time()) { + return 'pending'; + } + + if ($row->last_attempt_succeeded === 'y') { + return 'ok'; + } elseif ($row->last_attempt_succeeded === 'n') { + return 'critical'; + } else { + return 'unknown'; + } + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Job name'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['j' => 'director_job'], + [ + 'id' => 'j.id', + 'job_name' => 'j.job_name', + 'job_class' => 'j.job_class', + 'disabled' => 'j.disabled', + 'run_interval' => 'j.run_interval', + 'last_attempt_succeeded' => 'j.last_attempt_succeeded', + 'ts_last_attempt' => 'j.ts_last_attempt', + 'unixts_last_attempt' => 'UNIX_TIMESTAMP(j.ts_last_attempt)', + 'ts_last_error' => 'j.ts_last_error', + 'last_error_message' => 'j.last_error_message', + ] + )->order('job_name'); + } +} diff --git a/library/Director/Web/Table/NotificationTemplateUsageTable.php b/library/Director/Web/Table/NotificationTemplateUsageTable.php new file mode 100644 index 0000000..da411a3 --- /dev/null +++ b/library/Director/Web/Table/NotificationTemplateUsageTable.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +class NotificationTemplateUsageTable extends TemplateUsageTable +{ + public function getTypes() + { + return [ + 'templates' => $this->translate('Templates'), + 'applyrules' => $this->translate('Apply Rules'), + ]; + } + + protected function getTypeSummaryDefinitions() + { + return [ + 'templates' => $this->getSummaryLine('template'), + 'applyrules' => $this->getSummaryLine('apply', 'o.host_id IS NULL'), + ]; + } +} diff --git a/library/Director/Web/Table/ObjectSetTable.php b/library/Director/Web/Table/ObjectSetTable.php new file mode 100644 index 0000000..2773841 --- /dev/null +++ b/library/Director/Web/Table/ObjectSetTable.php @@ -0,0 +1,211 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Db\DbSelectParenthesis; +use Icinga\Module\Director\Restriction\FilterByNameRestriction; +use ipl\Html\Html; +use Ramsey\Uuid\Uuid; + +class ObjectSetTable extends ZfQueryBasedTable +{ + use TableWithBranchSupport; + + protected $searchColumns = [ + 'os.object_name', + 'os.description', + 'os.assign_filter', + 'o.object_name', + ]; + + private $type; + + /** @var Auth */ + private $auth; + + public static function create($type, Db $db, Auth $auth) + { + $table = new static($db); + $table->type = $type; + $table->auth = $auth; + return $table; + } + + public function getType() + { + return $this->type; + } + + public function getColumnsToBeRendered() + { + return [$this->translate('Name')]; + } + + public function renderRow($row) + { + $type = $this->getType(); + $params = [ + 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(), + ]; + + $url = Url::fromPath("director/${type}set", $params); + + $classes = $this->getRowClasses($row); + $tr = static::tr([ + static::td([ + Link::create(sprintf( + $this->translate('%s (%d members)'), + $row->object_name, + $row->count_services + ), $url), + $row->description ? [Html::tag('br'), Html::tag('i', $row->description)] : null + ]) + ]); + if (! empty($classes)) { + $tr->getAttributes()->add('class', $classes); + } + + return $tr; + } + + protected function getRowClasses($row) + { + if ($row->branch_uuid !== null) { + return ['branch_modified']; + } + return []; + } + + protected function prepareQuery() + { + $type = $this->getType(); + + $table = "icinga_${type}_set"; + $columns = [ + 'id' => 'os.id', + 'uuid' => 'os.uuid', + 'branch_uuid' => '(NULL)', + 'object_name' => 'os.object_name', + 'object_type' => 'os.object_type', + 'assign_filter' => 'os.assign_filter', + 'description' => 'os.description', + 'count_services' => 'COUNT(DISTINCT o.uuid)', + ]; + if ($this->branchUuid) { + $columns['branch_uuid'] = 'bos.branch_uuid'; + $columns = $this->branchifyColumns($columns); + $this->stripSearchColumnAliases(); + } + + $query = $this->db()->select()->from( + ['os' => $table], + $columns + )->joinLeft( + ['o' => "icinga_${type}"], + "o.${type}_set_id = os.id", + [] + ); + + $nameFilter = new FilterByNameRestriction( + $this->connection(), + $this->auth, + "${type}_set" + ); + $nameFilter->applyToQuery($query, 'os'); + /** @var Db $conn */ + $conn = $this->connection(); + if ($this->branchUuid) { + $right = clone($query); + + $query->joinLeft( + ['bos' => "branched_$table"], + // TODO: PgHexFunc + $this->db()->quoteInto( + 'bos.uuid = os.uuid AND bos.branch_uuid = ?', + $conn->quoteBinary($this->branchUuid->getBytes()) + ), + [] + )->where("(bos.branch_deleted IS NULL OR bos.branch_deleted = 'n')"); + $right->joinRight( + ['bos' => "branched_$table"], + 'bos.uuid = os.uuid', + [] + ) + ->where('os.uuid IS NULL') + ->where('bos.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes())); + $query->group('COALESCE(os.uuid, bos.uuid)'); + $right->group('COALESCE(os.uuid, bos.uuid)'); + if ($conn->isPgsql()) { + // This is ugly, might want to modify the query - even a subselect looks better + $query->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid'); + $right->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid'); + } + + $query = $this->db()->select()->union([ + 'l' => new DbSelectParenthesis($query), + 'r' => new DbSelectParenthesis($right), + ]); + $query = $this->db()->select()->from(['u' => $query]); + $query->order('object_name')->limit(100); + + $query + ->group('uuid') + ->where('object_type = ?', 'template') + ->order('object_name'); + if ($conn->isPgsql()) { + // BS. Drop count? Sub-select? Better query? + $query + ->group('uuid') + ->group('id') + ->group('branch_uuid') + ->group('object_name') + ->group('object_type') + ->group('assign_filter') + ->group('description') + ->group('count_services'); + }; + } else { + // Disabled for now, check for correctness: + // $query->joinLeft( + // ['osi' => "icinga_${type}_set_inheritance"], + // "osi.parent_${type}_set_id = os.id", + // [] + // )->joinLeft( + // ['oso' => "icinga_${type}_set"], + // "oso.id = oso.${type}_set_id", + // [] + // ); + // 'count_hosts' => 'COUNT(DISTINCT oso.id)', + + $query + ->group('os.uuid') + ->where('os.object_type = ?', 'template') + ->order('os.object_name'); + if ($conn->isPgsql()) { + // BS. Drop count? Sub-select? Better query? + $query + ->group('os.uuid') + ->group('os.id') + ->group('os.object_name') + ->group('os.object_type') + ->group('os.assign_filter') + ->group('os.description'); + }; + } + + return $query; + } + + /** + * @return Db + */ + public function connection() + { + return parent::connection(); + } +} diff --git a/library/Director/Web/Table/ObjectsTable.php b/library/Director/Web/Table/ObjectsTable.php new file mode 100644 index 0000000..792cb6d --- /dev/null +++ b/library/Director/Web/Table/ObjectsTable.php @@ -0,0 +1,315 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbSelectParenthesis; +use Icinga\Module\Director\Db\IcingaObjectFilterHelper; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Restriction\FilterByNameRestriction; +use Icinga\Module\Director\Restriction\HostgroupRestriction; +use Icinga\Module\Director\Restriction\ObjectRestriction; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; +use Ramsey\Uuid\Uuid; +use Zend_Db_Select as ZfSelect; + +class ObjectsTable extends ZfQueryBasedTable +{ + use TableWithBranchSupport; + + /** @var ObjectRestriction[] */ + protected $objectRestrictions; + + protected $columns = [ + 'object_name' => 'o.object_name', + 'object_type' => 'o.object_type', + 'disabled' => 'o.disabled', + 'uuid' => 'o.uuid', + ]; + + protected $searchColumns = ['o.object_name']; + + protected $showColumns = ['object_name' => 'Name']; + + protected $filterObjectType = 'object'; + + protected $type; + + protected $baseObjectUrl; + + /** @var IcingaObject */ + protected $dummyObject; + + protected $leftSubQuery; + + protected $rightSubQuery; + + /** @var Auth */ + private $auth; + + /** + * @param $type + * @param Db $db + * @return static + */ + public static function create($type, Db $db) + { + $class = __NAMESPACE__ . '\\ObjectsTable' . ucfirst($type); + if (! class_exists($class)) { + $class = __CLASS__; + } + + /** @var static $table */ + $table = new $class($db); + $table->type = $type; + return $table; + } + + public function getType() + { + return $this->type; + } + + /** + * @param string $url + * @return $this + */ + public function setBaseObjectUrl($url) + { + $this->baseObjectUrl = $url; + + return $this; + } + + /** + * @return Auth + */ + public function getAuth() + { + return $this->auth; + } + + public function setAuth(Auth $auth) + { + $this->auth = $auth; + return $this; + } + + public function filterObjectType($type) + { + $this->filterObjectType = $type; + return $this; + } + + public function addObjectRestriction(ObjectRestriction $restriction) + { + $this->objectRestrictions[$restriction->getName()] = $restriction; + return $this; + } + + public function getColumns() + { + return $this->columns; + } + + public function getColumnsToBeRendered() + { + return $this->showColumns; + } + + public function filterTemplate( + IcingaObject $template, + $inheritance = Db\IcingaObjectFilterHelper::INHERIT_DIRECT + ) { + IcingaObjectFilterHelper::filterByTemplate( + $this->getQuery(), + $template, + 'o', + $inheritance + ); + + return $this; + } + + protected function getMainLinkLabel($row) + { + return $row->object_name; + } + + protected function renderObjectNameColumn($row) + { + $type = $this->baseObjectUrl; + $url = Url::fromPath("director/${type}", [ + 'uuid' => Uuid::fromBytes($row->uuid)->toString() + ]); + + return static::td(Link::create($this->getMainLinkLabel($row), $url)); + } + + protected function renderExtraColumns($row) + { + $columns = $this->getColumnsToBeRendered(); + unset($columns['object_name']); + $cols = []; + foreach ($columns as $key => & $label) { + $cols[] = static::td($row->$key); + } + + return $cols; + } + + public function renderRow($row) + { + if (isset($row->uuid) && is_resource($row->uuid)) { + $row->uuid = stream_get_contents($row->uuid); + } + $tr = static::tr([ + $this->renderObjectNameColumn($row), + $this->renderExtraColumns($row) + ]); + + $classes = $this->getRowClasses($row); + if ($row->disabled === 'y') { + $classes[] = 'disabled'; + } + if (! empty($classes)) { + $tr->getAttributes()->add('class', $classes); + } + + return $tr; + } + + protected function getRowClasses($row) + { + // TODO: remove isset, to figure out where it is missing + if (isset($row->branch_uuid) && $row->branch_uuid !== null) { + return ['branch_modified']; + } + return []; + } + + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + if ($right) { + $right->where( + 'bo.object_type = ?', + $this->filterObjectType + ); + } + return $query->where( + 'o.object_type = ?', + $this->filterObjectType + ); + } + + protected function applyRestrictions(ZfSelect $query) + { + foreach ($this->getRestrictions() as $restriction) { + $restriction->applyToQuery($query); + } + + return $query; + } + + protected function getRestrictions() + { + if ($this->objectRestrictions === null) { + $this->objectRestrictions = $this->loadRestrictions(); + } + + return $this->objectRestrictions; + } + + protected function loadRestrictions() + { + /** @var Db $db */ + $db = $this->connection(); + $auth = $this->getAuth(); + + return [ + new HostgroupRestriction($db, $auth), + new FilterByNameRestriction($db, $auth, $this->getDummyObject()->getShortTableName()) + ]; + } + + /** + * @return IcingaObject + */ + protected function getDummyObject() + { + if ($this->dummyObject === null) { + $type = $this->getType(); + $this->dummyObject = IcingaObject::createByType($type); + } + return $this->dummyObject; + } + + protected function prepareQuery() + { + $table = $this->getDummyObject()->getTableName(); + if ($this->branchUuid) { + $this->columns['branch_uuid'] = 'bo.branch_uuid'; + } + + $columns = $this->getColumns(); + if ($this->branchUuid) { + $columns = $this->branchifyColumns($columns); + $this->stripSearchColumnAliases(); + } + $query = $this->db()->select()->from(['o' => $table], $columns); + + if ($this->branchUuid) { + $right = clone($query); + // Hint: Right part has only those with object = null + // This means that restrictions on $right would hide all + // new rows. Dedicated restriction logic for the branch-only + // part of thw union are not required, we assume that restrictions + // for new objects have been checked once they have been created + $query = $this->applyRestrictions($query); + /** @var Db $conn */ + $conn = $this->connection(); + $query->joinLeft( + ['bo' => "branched_$table"], + // TODO: PgHexFunc + $this->db()->quoteInto( + 'bo.uuid = o.uuid AND bo.branch_uuid = ?', + $conn->quoteBinary($this->branchUuid->getBytes()) + ), + [] + )->where("(bo.branch_deleted IS NULL OR bo.branch_deleted = 'n')"); + $this->applyObjectTypeFilter($query, $right); + $right->joinRight( + ['bo' => "branched_$table"], + 'bo.uuid = o.uuid', + [] + ) + ->where('o.uuid IS NULL') + ->where('bo.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes())); + $this->leftSubQuery = $query; + $this->rightSubQuery = $right; + $query = $this->db()->select()->union([ + 'l' => new DbSelectParenthesis($query), + 'r' => new DbSelectParenthesis($right), + ]); + $query = $this->db()->select()->from(['u' => $query]); + $query->order('object_name')->limit(100); + } else { + $this->applyObjectTypeFilter($query); + $query->order('o.object_name')->limit(100); + } + + return $query; + } + + public function removeQueryLimit() + { + $query = $this->getQuery(); + $query->reset($query::LIMIT_OFFSET); + $query->reset($query::LIMIT_COUNT); + + return $this; + } +} diff --git a/library/Director/Web/Table/ObjectsTableApiUser.php b/library/Director/Web/Table/ObjectsTableApiUser.php new file mode 100644 index 0000000..2287c2f --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableApiUser.php @@ -0,0 +1,13 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Zend_Db_Select as ZfSelect; + +class ObjectsTableApiUser extends ObjectsTable +{ + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + return $query->where("o.object_type IN ('object', 'external_object')"); + } +} diff --git a/library/Director/Web/Table/ObjectsTableCommand.php b/library/Director/Web/Table/ObjectsTableCommand.php new file mode 100644 index 0000000..ebd89da --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableCommand.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Zend_Db_Select as ZfSelect; + +class ObjectsTableCommand extends ObjectsTable implements FilterableByUsage +{ + // TODO: Notifications separately? + protected $searchColumns = [ + 'o.object_name', + 'o.command', + ]; + + protected $columns = [ + 'uuid' => 'o.uuid', + 'object_name' => 'o.object_name', + 'object_type' => 'o.object_type', + 'disabled' => 'o.disabled', + 'command' => 'o.command', + ]; + + protected $showColumns = [ + 'object_name' => 'Command', + 'command' => 'Command line' + ]; + + private $objectType; + + public function setType($type) + { + $this->getQuery()->where('object_type = ?', $type); + + return $this; + } + + public function showOnlyUsed() + { + $this->getQuery()->where( + '(' + . 'EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)' + . ' OR EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)' + . ' OR EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)' + . ' OR EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)' + . ' OR EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)' + . ')' + ); + } + + public function showOnlyUnUsed() + { + $this->getQuery()->where( + '(' + . 'NOT EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)' + . ' AND NOT EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)' + . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)' + . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)' + . ' AND NOT EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)' + . ')' + ); + } + + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + return $query; + } +} diff --git a/library/Director/Web/Table/ObjectsTableEndpoint.php b/library/Director/Web/Table/ObjectsTableEndpoint.php new file mode 100644 index 0000000..f73b38b --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableEndpoint.php @@ -0,0 +1,86 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Icon; +use Zend_Db_Select as ZfSelect; + +class ObjectsTableEndpoint extends ObjectsTable +{ + protected $searchColumns = [ + 'o.object_name', + ]; + + protected $deploymentEndpoint; + + public function getColumnsToBeRendered() + { + return array( + 'object_name' => $this->translate('Endpoint'), + 'host' => $this->translate('Host'), + 'zone' => $this->translate('Zone'), + 'object_type' => $this->translate('Type'), + ); + } + + public function getColumns() + { + return [ + 'uuid' => 'o.uuid', + 'object_name' => 'o.object_name', + 'object_type' => 'o.object_type', + 'disabled' => 'o.disabled', + 'host' => "(CASE WHEN o.host IS NULL THEN NULL ELSE" + . " CONCAT(o.host || ':' || COALESCE(o.port, 5665)) END)", + 'zone' => 'z.object_name', + ]; + } + + protected function getMainLinkLabel($row) + { + if ($row->object_name === $this->deploymentEndpoint) { + return [ + $row->object_name, + ' ', + Icon::create('upload', [ + 'title' => $this->translate( + 'This is your Config master and will receive our Deployments' + ) + ]) + ]; + } else { + return $row->object_name; + } + } + + public function getRowClasses($row) + { + if ($row->object_name === $this->deploymentEndpoint) { + return array_merge(array('deployment-endpoint'), parent::getRowClasses($row)); + } else { + return null; + } + } + + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + return $query->where("o.object_type IN ('object', 'external_object')"); + } + + public function prepareQuery() + { + if ($this->deploymentEndpoint === null) { + /** @var \Icinga\Module\Director\Db $c */ + $c = $this->connection(); + if ($c->hasDeploymentEndpoint()) { + $this->deploymentEndpoint = $c->getDeploymentEndpointName(); + } + } + + return parent::prepareQuery()->joinLeft( + ['z' => 'icinga_zone'], + 'o.zone_id = z.id', + [] + ); + } +} diff --git a/library/Director/Web/Table/ObjectsTableHost.php b/library/Director/Web/Table/ObjectsTableHost.php new file mode 100644 index 0000000..5128e04 --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableHost.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Table\Extension\MultiSelect; + +class ObjectsTableHost extends ObjectsTable +{ + use MultiSelect; + + protected $type = 'host'; + + protected $searchColumns = [ + 'o.object_name', + 'o.display_name', + 'o.address', + ]; + + protected $columns = [ + 'object_name' => 'o.object_name', + 'display_name' => 'o.display_name', + 'address' => 'o.address', + 'disabled' => 'o.disabled', + 'uuid' => 'o.uuid', + ]; + + protected $showColumns = [ + 'object_name' => 'Hostname', + 'address' => 'Address' + ]; + + public function assemble() + { + $this->enableMultiSelect( + 'director/hosts/edit', + 'director/hosts', + ['uuid'] + ); + } +} diff --git a/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php new file mode 100644 index 0000000..929e050 --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Zend_Db_Select as ZfSelect; + +class ObjectsTableHostTemplateChoice extends ObjectsTable +{ + protected $columns = [ + 'object_name' => 'o.object_name', + 'templates' => 'GROUP_CONCAT(t.object_name)' + ]; + + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + return $query; + } + + protected function prepareQuery() + { + return parent::prepareQuery()->joinLeft( + ['t' => 'icinga_host'], + 't.template_choice_id = o.id', + [] + )->group('o.id'); + } +} diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php new file mode 100644 index 0000000..2d4ad41 --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableService.php @@ -0,0 +1,219 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db\DbUtil; +use Icinga\Module\Director\Objects\IcingaHost; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Table\Extension\MultiSelect; +use gipfl\IcingaWeb2\Link; +use Ramsey\Uuid\Uuid; + +class ObjectsTableService extends ObjectsTable +{ + use MultiSelect; + + /** @var IcingaHost */ + protected $host; + + protected $type = 'service'; + + protected $title; + + /** @var IcingaHost */ + protected $inheritedBy; + + /** @var bool */ + protected $readonly = false; + + /** @var string|null */ + protected $highlightedService; + + protected $columns = [ + 'object_name' => 'o.object_name', + 'disabled' => 'o.disabled', + 'host' => 'h.object_name', + 'host_id' => 'h.id', + 'host_object_type' => 'h.object_type', + 'host_disabled' => 'h.disabled', + 'id' => 'o.id', + 'uuid' => 'o.uuid', + 'blacklisted' => "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END", + ]; + + protected $searchColumns = [ + 'o.object_name', + 'h.object_name' + ]; + + public function assemble() + { + $this->enableMultiSelect( + 'director/services/edit', + 'director/services', + ['uuid'] + ); + } + + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + public function setHost(IcingaHost $host) + { + $this->host = $host; + $this->getAttributes()->set('data-base-target', '_self'); + return $this; + } + + public function setInheritedBy(IcingaHost $host) + { + $this->inheritedBy = $host; + return $this; + } + + /** + * Show no related links + * + * @param bool $readonly + * @return $this + */ + public function setReadonly($readonly = true) + { + $this->readonly = (bool) $readonly; + + return $this; + } + + public function highlightService($service) + { + $this->highlightedService = $service; + + return $this; + } + + public function getColumnsToBeRendered() + { + if ($this->title) { + return [$this->title]; + } + if ($this->host) { + return [$this->translate('Servicename')]; + } + return [ + 'host' => $this->translate('Host'), + 'object_name' => $this->translate('Service Name'), + ]; + } + + public function renderRow($row) + { + $caption = $row->host === null + ? Html::tag('span', ['class' => 'error'], '- none -') + : $row->host; + + $hostField = static::td($caption); + if ($row->host === null) { + $hostField->getAttributes()->add('class', 'error'); + } + if ($this->host) { + $tr = static::tr([ + static::td($this->getServiceLink($row)) + ]); + } else { + $tr = static::tr([ + $hostField, + static::td($this->getServiceLink($row)) + ]); + } + + $attributes = $tr->getAttributes(); + $classes = $this->getRowClasses($row); + if ($row->host_disabled === 'y' || $row->disabled === 'y') { + $classes[] = 'disabled'; + } + if ($row->blacklisted === 'y') { + $classes[] = 'strike-links'; + } + $attributes->add('class', $classes); + + return $tr; + } + + protected function getInheritedServiceLink($row, $target) + { + $params = [ + 'name' => $target->object_name, + 'service' => $row->object_name, + 'inheritedFrom' => $row->host, + ]; + + return Link::create( + $row->object_name, + 'director/host/inheritedservice', + $params + ); + } + + protected function getServiceLink($row) + { + if ($this->readonly) { + if ($this->highlightedService === $row->object_name) { + return Html::tag('span', ['class' => 'icon-right-big'], $row->object_name); + } else { + return $row->object_name; + } + } + + $params = [ + 'uuid' => Uuid::fromBytes(DbUtil::binaryResult($row->uuid))->toString(), + ]; + if ($row->host !== null) { + $params['host'] = $row->host; + } + if ($target = $this->inheritedBy) { + return $this->getInheritedServiceLink($row, $target); + } + + return Link::create( + $row->object_name, + 'director/service/edit', + $params + ); + } + + public function prepareQuery() + { + $query = parent::prepareQuery(); + if ($this->branchUuid) { + $queries = [$this->leftSubQuery, $this->rightSubQuery]; + } else { + $queries = [$query]; + } + + foreach ($queries as $subQuery) { + $subQuery->joinLeft( + ['h' => 'icinga_host'], + 'o.host_id = h.id', + [] + )->joinLeft( + ['hsb' => 'icinga_host_service_blacklist'], + 'hsb.service_id = o.id AND hsb.host_id = o.host_id', + [] + )->where('o.service_set_id IS NULL') + ->order('o.object_name')->order('h.object_name'); + + if ($this->host) { + if ($this->branchUuid) { + $subQuery->where('COALESCE(h.object_name, bo.host) = ?', $this->host->getObjectName()); + } else { + $subQuery->where('h.id = ?', $this->host->get('id')); + } + } + } + + return $query; + } +} diff --git a/library/Director/Web/Table/ObjectsTableZone.php b/library/Director/Web/Table/ObjectsTableZone.php new file mode 100644 index 0000000..602cf0a --- /dev/null +++ b/library/Director/Web/Table/ObjectsTableZone.php @@ -0,0 +1,13 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Zend_Db_Select as ZfSelect; + +class ObjectsTableZone extends ObjectsTable +{ + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) + { + return $query; + } +} diff --git a/library/Director/Web/Table/PropertymodifierTable.php b/library/Director/Web/Table/PropertymodifierTable.php new file mode 100644 index 0000000..bf9e4a3 --- /dev/null +++ b/library/Director/Web/Table/PropertymodifierTable.php @@ -0,0 +1,145 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Error; +use Exception; +use Icinga\Module\Director\Hook\ImportSourceHook; +use Icinga\Module\Director\Objects\ImportSource; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; + +class PropertymodifierTable extends ZfQueryBasedTable +{ + use ZfSortablePriority; + + protected $searchColumns = [ + 'property_name', + 'target_property', + ]; + + /** @var ImportSource */ + protected $source; + + /** @var Url */ + protected $url; + + protected $keyColumn = 'id'; + + protected $priorityColumn = 'priority'; + + protected $readOnly = false; + + public static function load(ImportSource $source, Url $url) + { + $table = new static($source->getConnection()); + $table->source = $source; + $table->url = $url; + return $table; + } + + public function setReadOnly($readOnly = true) + { + $this->readOnly = $readOnly; + return $this; + } + + public function render() + { + if ($this->readOnly) { + return parent::render(); + } + return $this->renderWithSortableForm(); + } + + protected function assemble() + { + $this->getAttributes()->set('data-base-target', '_self'); + } + + public function getColumns() + { + return array( + 'id' => 'm.id', + 'source_id' => 'm.source_id', + 'property_name' => 'm.property_name', + 'target_property' => 'm.target_property', + 'description' => 'm.description', + 'provider_class' => 'm.provider_class', + 'priority' => 'm.priority', + ); + } + + public function renderRow($row) + { + $caption = $row->property_name; + if ($row->target_property !== null) { + $caption .= ' -> ' . $row->target_property; + } + if ($row->description === null) { + $class = $row->provider_class; + try { + /** @var ImportSourceHook $hook */ + $hook = new $class; + $caption .= ': ' . $hook->getName(); + } catch (Exception $e) { + $caption = $this->createErrorCaption($caption, $e); + } catch (Error $e) { + $caption = $this->createErrorCaption($caption, $e); + } + } else { + $caption .= ': ' . $row->description; + } + + $renderedRow = $this::row([ + Link::create($caption, 'director/importsource/editmodifier', [ + 'id' => $row->id, + 'source_id' => $row->source_id, + ]), + ]); + if ($this->readOnly) { + return $renderedRow; + } + + return $this->addSortPriorityButtons( + $renderedRow, + $row + ); + } + + /** + * @param $caption + * @param Exception|Error $e + * @return array + */ + protected function createErrorCaption($caption, $e) + { + return [ + $caption, + ': ', + $this::tag('span', ['class' => 'error'], $e->getMessage()) + ]; + } + + public function getColumnsToBeRendered() + { + if ($this->readOnly) { + return [$this->translate('Property')]; + } + return [ + $this->translate('Property'), + $this->getSortPriorityTitle() + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['m' => 'import_row_modifier'], + $this->getColumns() + )->where('m.source_id = ?', $this->source->get('id')) + ->order('priority'); + } +} diff --git a/library/Director/Web/Table/QuickTable.php b/library/Director/Web/Table/QuickTable.php new file mode 100644 index 0000000..ff3edcc --- /dev/null +++ b/library/Director/Web/Table/QuickTable.php @@ -0,0 +1,547 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Application\Icinga; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterOr; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Selectable; +use Icinga\Data\Paginatable; +use Icinga\Exception\QueryException; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\PlainObjectRenderer; +use Icinga\Web\Request; +use gipfl\IcingaWeb2\Url; +use Icinga\Web\View; +use Icinga\Web\Widget; +use Icinga\Web\Widget\Paginator; +use ipl\Html\ValidHtml; +use stdClass; +use Zend_Db_Select as ZfDbSelect; + +abstract class QuickTable implements Paginatable, ValidHtml +{ + protected $view; + + /** @var Db */ + protected $connection; + + protected $limit; + + protected $offset; + + /** @var Filter */ + protected $filter; + + protected $enforcedFilters = array(); + + protected $searchColumns = array(); + + protected function getRowClasses($row) + { + return array(); + } + + protected function getRowClassesString($row) + { + return $this->createClassAttribute($this->getRowClasses($row)); + } + + protected function createClassAttribute($classes) + { + $str = $this->createClassesString($classes); + if (strlen($str) > 0) { + return ' class="' . $str . '"'; + } else { + return ''; + } + } + + private function createClassesString($classes) + { + if (is_string($classes)) { + $classes = array($classes); + } + + if (empty($classes)) { + return ''; + } else { + return implode(' ', $classes); + } + } + + protected function getMultiselectProperties() + { + /* array( + * 'url' => 'director/hosts/edit', + * 'sourceUrl' => 'director/hosts', + * 'keys' => 'name' + * ) */ + + return array(); + } + + protected function renderMultiselectAttributes() + { + $props = $this->getMultiselectProperties(); + + if (empty($props)) { + return ''; + } + + $prefix = 'data-icinga-multiselect-'; + $view = $this->view(); + $parts = array(); + $multi = array( + 'url' => $view->href($props['url']), + 'controllers' => $view->href($props['sourceUrl']), + 'data' => implode(',', $props['keys']), + ); + + foreach ($multi as $k => $v) { + $parts[] = $prefix . $k . '="' . $v . '"'; + } + + return ' ' . implode(' ', $parts); + } + + protected function renderRow($row) + { + $htm = " <tr" . $this->getRowClassesString($row) . ">\n"; + $firstCol = true; + + foreach ($this->getTitles() as $key => $title) { + // Support missing columns + if (property_exists($row, $key)) { + $val = $row->$key; + } else { + $val = null; + } + + $value = null; + + if ($firstCol) { + if ($val !== null && $url = $this->getActionUrl($row)) { + $value = $this->view()->qlink($val, $this->getActionUrl($row)); + } + $firstCol = false; + } + + if ($value === null) { + if ($val === null) { + $value = '-'; + } elseif (is_array($val) || $val instanceof stdClass || is_bool($val)) { + $value = '<pre>' + . $this->view()->escape(PlainObjectRenderer::render($val)) + . '</pre>'; + } else { + $value = $this->view()->escape($val); + } + } + + $htm .= ' <td>' . $value . "</td>\n"; + } + + if ($this->hasAdditionalActions()) { + $htm .= ' <td class="actions">' . $this->renderAdditionalActions($row) . "</td>\n"; + } + + return $htm . " </tr>\n"; + } + + abstract protected function getTitles(); + + protected function getActionUrl($row) + { + return false; + } + + public function setConnection(Selectable $connection) + { + $this->connection = $connection; + return $this; + } + + /** + * @return ZfDbSelect + */ + abstract protected function getBaseQuery(); + + public function fetchData() + { + $db = $this->db(); + $query = $this->getBaseQuery()->columns($this->getColumns()); + + if ($this->hasLimit() || $this->hasOffset()) { + $query->limit($this->getLimit(), $this->getOffset()); + } + + $this->applyFiltersToQuery($query); + + return $db->fetchAll($query); + } + + protected function applyFiltersToQuery(ZfDbSelect $query) + { + $filter = null; + $enforced = $this->enforcedFilters; + if ($this->filter && ! $this->filter->isEmpty()) { + $filter = $this->filter; + } elseif (! empty($enforced)) { + $filter = array_shift($enforced); + } + if ($filter) { + foreach ($enforced as $f) { + $filter = $filter->andFilter($f); + } + $query->where($this->renderFilter($filter)); + } + + return $query; + } + + public function getPaginator() + { + $paginator = new Paginator(); + $paginator->setQuery($this); + + return $paginator; + } + + #[\ReturnTypeWillChange] + public function count() + { + $db = $this->db(); + $query = clone($this->getBaseQuery()); + $query->reset('order')->columns(array('COUNT(*)')); + $this->applyFiltersToQuery($query); + + return $db->fetchOne($query); + } + + public function limit($count = null, $offset = null) + { + $this->limit = $count; + $this->offset = $offset; + + return $this; + } + + public function hasLimit() + { + return $this->limit !== null; + } + + public function getLimit() + { + return $this->limit; + } + + public function hasOffset() + { + return $this->offset !== null; + } + + public function getOffset() + { + return $this->offset; + } + + public function hasAdditionalActions() + { + return method_exists($this, 'renderAdditionalActions'); + } + + /** @return Db */ + protected function connection() + { + // TODO: Fail if missing? Require connection in constructor? + return $this->connection; + } + + protected function db() + { + return $this->connection()->getDbAdapter(); + } + + protected function renderTitles($row) + { + $view = $this->view(); + $htm = "<thead>\n <tr>\n"; + + foreach ($row as $title) { + $htm .= ' <th>' . $view->escape($title) . "</th>\n"; + } + + if ($this->hasAdditionalActions()) { + $htm .= ' <th class="actions">' . $view->translate('Actions') . "</th>\n"; + } + + return $htm . " </tr>\n</thead>\n"; + } + + protected function url($url, $params) + { + return Url::fromPath($url, $params); + } + + protected function listTableClasses() + { + $classes = array('simple', 'common-table', 'table-row-selectable'); + $multi = $this->getMultiselectProperties(); + if (! empty($multi)) { + $classes[] = 'multiselect'; + } + + return $classes; + } + + public function render() + { + $data = $this->fetchData(); + + $htm = '<table' + . $this->createClassAttribute($this->listTableClasses()) + . $this->renderMultiselectAttributes() + . '>' . "\n" + . $this->renderTitles($this->getTitles()) + . $this->beginTableBody(); + foreach ($data as $row) { + $htm .= $this->renderRow($row); + } + return $htm . $this->endTableBody() . $this->endTable(); + } + + protected function beginTableBody() + { + return "<tbody>\n"; + } + + protected function endTableBody() + { + return "</tbody>\n"; + } + + protected function endTable() + { + return "</table>\n"; + } + + /** + * @return View + */ + protected function view() + { + if ($this->view === null) { + $this->view = Icinga::app()->getViewRenderer()->view; + } + return $this->view; + } + + + public function setView($view) + { + $this->view = $view; + } + + public function __toString() + { + return $this->render(); + } + + protected function getSearchColumns() + { + return $this->searchColumns; + } + + abstract public function getColumns(); + + public function getFilterColumns() + { + $keys = array_keys($this->getColumns()); + return array_combine($keys, $keys); + } + + public function setFilter($filter) + { + $this->filter = $filter; + return $this; + } + + public function enforceFilter($filter, $expression = null) + { + if (! $filter instanceof Filter) { + $filter = Filter::where($filter, $expression); + } + $this->enforcedFilters[] = $filter; + return $this; + } + + public function getFilterEditor(Request $request) + { + $filterEditor = Widget::create('filterEditor') + ->setColumns(array_keys($this->getColumns())) + ->setSearchColumns($this->getSearchColumns()) + ->preserveParams('limit', 'sort', 'dir', 'view', 'backend', '_dev') + ->ignoreParams('page') + ->handleRequest($request); + + $filter = $filterEditor->getFilter(); + $this->setFilter($filter); + + return $filterEditor; + } + + protected function mapFilterColumn($col) + { + $cols = $this->getColumns(); + return $cols[$col]; + } + + protected function renderFilter(Filter $filter, $level = 0) + { + $str = ''; + if ($filter instanceof FilterChain) { + if ($filter instanceof FilterAnd) { + $op = ' AND '; + } elseif ($filter instanceof FilterOr) { + $op = ' OR '; + } elseif ($filter instanceof FilterNot) { + $op = ' AND '; + $str .= ' NOT '; + } else { + throw new QueryException( + 'Cannot render filter: %s', + $filter + ); + } + $parts = array(); + if (! $filter->isEmpty()) { + foreach ($filter->filters() as $f) { + $filterPart = $this->renderFilter($f, $level + 1); + if ($filterPart !== '') { + $parts[] = $filterPart; + } + } + if (! empty($parts)) { + if ($level > 0) { + $str .= ' (' . implode($op, $parts) . ') '; + } else { + $str .= implode($op, $parts); + } + } + } + } else { + /** @var FilterExpression $filter */ + $str .= $this->whereToSql( + $this->mapFilterColumn($filter->getColumn()), + $filter->getSign(), + $filter->getExpression() + ); + } + + return $str; + } + + protected function escapeForSql($value) + { + // bindParam? bindValue? + if (is_array($value)) { + $ret = array(); + foreach ($value as $val) { + $ret[] = $this->escapeForSql($val); + } + return implode(', ', $ret); + } else { + //if (preg_match('/^\d+$/', $value)) { + // return $value; + //} else { + return $this->db()->quote($value); + //} + } + } + + protected function escapeWildcards($value) + { + return preg_replace('/\*/', '%', $value); + } + + protected function valueToTimestamp($value) + { + // We consider integers as valid timestamps. Does not work for URL params + if (! is_string($value) || ctype_digit($value)) { + return $value; + } + $value = strtotime($value); + if (! $value) { + /* + NOTE: It's too late to throw exceptions, we might finish in __toString + throw new QueryException(sprintf( + '"%s" is not a valid time expression', + $value + )); + */ + } + return $value; + } + + protected function timestampForSql($value) + { + // TODO: do this db-aware + return $this->escapeForSql(date('Y-m-d H:i:s', $value)); + } + + /** + * Check for timestamp fields + * + * TODO: This is not here to do automagic timestamp stuff. One may + * override this function for custom voodoo, IdoQuery right now + * does. IMO we need to split whereToSql functionality, however + * I'd prefer to wait with this unless we understood how other + * backends will work. We probably should also rename this + * function to isTimestampColumn(). + * + * @param string $field Field Field name to checked + * @return bool Whether this field expects timestamps + */ + public function isTimestamp($field) + { + return false; + } + + public function whereToSql($col, $sign, $expression) + { + if ($this->isTimestamp($col)) { + $expression = $this->valueToTimestamp($expression); + } + + if (is_array($expression) && $sign === '=') { + // TODO: Should we support this? Doesn't work for blub* + return $col . ' IN (' . $this->escapeForSql($expression) . ')'; + } elseif ($sign === '=' && strpos($expression, '*') !== false) { + if ($expression === '*') { + // We'll ignore such filters as it prevents index usage and because "*" means anything, anything means + // all whereas all means that whether we use a filter to match anything or no filter at all makes no + // difference, except for performance reasons... + return ''; + } + + return $col . ' LIKE ' . $this->escapeForSql($this->escapeWildcards($expression)); + } elseif ($sign === '!=' && strpos($expression, '*') !== false) { + if ($expression === '*') { + // We'll ignore such filters as it prevents index usage and because "*" means nothing, so whether we're + // using a real column with a valid comparison here or just an expression which cannot be evaluated to + // true makes no difference, except for performance reasons... + return $this->escapeForSql(0); + } + + return $col . ' NOT LIKE ' . $this->escapeForSql($this->escapeWildcards($expression)); + } else { + return $col . ' ' . $sign . ' ' . $this->escapeForSql($expression); + } + } +} diff --git a/library/Director/Web/Table/ReadOnlyFormAvpTable.php b/library/Director/Web/Table/ReadOnlyFormAvpTable.php new file mode 100644 index 0000000..c3b44f3 --- /dev/null +++ b/library/Director/Web/Table/ReadOnlyFormAvpTable.php @@ -0,0 +1,113 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\PlainObjectRenderer; +use Icinga\Module\Director\Web\Form\QuickForm; +use Zend_Form_Element as ZfElement; +use Zend_Form_DisplayGroup as ZfDisplayGroup; + +class ReadOnlyFormAvpTable +{ + protected $form; + + public function __construct(QuickForm $form) + { + $this->form = $form; + } + + protected function renderDisplayGroups(QuickForm $form) + { + $html = ''; + + foreach ($form->getDisplayGroups() as $group) { + $elements = $this->filterGroupElements($group); + + if (empty($elements)) { + continue; + } + + $html .= '<tr><th colspan="2" style="text-align: right">' . $group->getLegend() . '</th></tr>'; + $html .= $this->renderElements($elements); + } + + return $html; + } + + /** + * @param ZfDisplayGroup $group + * @return ZfElement[] + */ + protected function filterGroupElements(ZfDisplayGroup $group) + { + $blacklist = array('disabled', 'assign_filter'); + $elements = array(); + /** @var ZfElement $element */ + foreach ($group->getElements() as $element) { + if ($element->getValue() === null) { + continue; + } + + if ($element->getType() === 'Zend_Form_Element_Hidden') { + continue; + } + + if (in_array($element->getName(), $blacklist)) { + continue; + } + + + $elements[] = $element; + } + + return $elements; + } + + protected function renderElements($elements) + { + $html = ''; + foreach ($elements as $element) { + $html .= $this->renderElement($element); + } + + return $html; + } + + /** + * @param ZfElement $element + * + * @return string + */ + protected function renderElement(ZfElement $element) + { + $value = $element->getValue(); + return '<tr><th>' + . $this->escape($element->getLabel()) + . '</th><td>' + . $this->renderValue($value) + . '</td></tr>'; + } + + protected function renderValue($value) + { + if (is_string($value)) { + return $this->escape($value); + } elseif (is_array($value)) { + return $this->escape(implode(', ', $value)); + } + return $this->escape(PlainObjectRenderer::render($value)); + } + + protected function escape($string) + { + return htmlspecialchars($string); + } + + public function render() + { + $this->form->initializeForObject(); + return '<table class="name-value-table">' . "\n" + . $this->renderDisplayGroups($this->form) + . '</table>'; + } +} diff --git a/library/Director/Web/Table/ServiceTemplateUsageTable.php b/library/Director/Web/Table/ServiceTemplateUsageTable.php new file mode 100644 index 0000000..82f9643 --- /dev/null +++ b/library/Director/Web/Table/ServiceTemplateUsageTable.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +class ServiceTemplateUsageTable extends TemplateUsageTable +{ + public function getTypes() + { + return [ + 'templates' => $this->translate('Templates'), + 'objects' => $this->translate('Objects'), + 'applyrules' => $this->translate('Apply Rules'), + // 'setmembers' => $this->translate('Set Members'), + ]; + } + + protected function getTypeSummaryDefinitions() + { + return [ + 'templates' => $this->getSummaryLine('template'), + 'objects' => $this->getSummaryLine('object'), + 'applyrules' => $this->getSummaryLine('apply', 'o.service_set_id IS NULL'), + // TODO: re-enable + // 'setmembers' => $this->getSummaryLine('apply', 'o.service_set_id IS NOT NULL'), + ]; + } +} diff --git a/library/Director/Web/Table/SyncRunTable.php b/library/Director/Web/Table/SyncRunTable.php new file mode 100644 index 0000000..e08aad7 --- /dev/null +++ b/library/Director/Web/Table/SyncRunTable.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\Format\LocalTimeFormat; +use Icinga\Module\Director\Objects\SyncRule; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class SyncRunTable extends ZfQueryBasedTable +{ + /** @var SyncRule */ + protected $rule; + + protected $timeFormat; + + public function __construct(SyncRule $rule) + { + parent::__construct($rule->getConnection()); + $this->timeFormat = new LocalTimeFormat(); + $this->getAttributes() + ->set('data-base-target', '_self') + ->add('class', 'history'); + $this->rule = $rule; + } + + public function renderRow($row) + { + $time = strtotime($row->start_time); + $this->renderDayIfNew($time); + return $this::tr([ + $this::td($this->makeSummary($row)), + $this::td(new Link( + $this->timeFormat->getTime($time), + 'director/syncrule/history', + [ + 'id' => $row->rule_id, + 'run_id' => $row->id, + ] + )) + ]); + } + + protected function makeSummary($row) + { + $parts = []; + if ($row->objects_created > 0) { + $parts[] = sprintf( + $this->translate('%d created'), + $row->objects_created + ); + } + if ($row->objects_modified > 0) { + $parts[] = sprintf( + $this->translate('%d modified'), + $row->objects_modified + ); + } + if ($row->objects_deleted > 0) { + $parts[] = sprintf( + $this->translate('%d deleted'), + $row->objects_deleted + ); + } + + return implode(', ', $parts); + } + + public function prepareQuery() + { + return $this->db()->select()->from( + array('sr' => 'sync_run'), + [ + 'id' => 'sr.id', + 'rule_id' => 'sr.rule_id', + 'rule_name' => 'sr.rule_name', + 'start_time' => 'sr.start_time', + 'duration_ms' => 'sr.duration_ms', + 'objects_deleted' => 'sr.objects_deleted', + 'objects_created' => 'sr.objects_created', + 'objects_modified' => 'sr.objects_modified', + 'last_former_activity' => 'sr.last_former_activity', + 'last_related_activity' => 'sr.last_related_activity', + ] + )->where( + 'sr.rule_id = ?', + $this->rule->get('id') + )->order('start_time DESC'); + } +} diff --git a/library/Director/Web/Table/SyncpropertyTable.php b/library/Director/Web/Table/SyncpropertyTable.php new file mode 100644 index 0000000..79461ce --- /dev/null +++ b/library/Director/Web/Table/SyncpropertyTable.php @@ -0,0 +1,97 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Objects\SyncRule; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class SyncpropertyTable extends ZfQueryBasedTable +{ + use ZfSortablePriority; + + /** @var SyncRule */ + protected $rule; + + protected $searchColumns = [ + 'source_expression', + 'destination_field', + ]; + + protected $keyColumn = 'id'; + + protected $priorityColumn = 'priority'; + + public static function create(SyncRule $rule) + { + $table = new static($rule->getConnection()); + $table->getAttributes()->set('data-base-target', '_self'); + $table->rule = $rule; + return $table; + } + + public function render() + { + return $this->renderWithSortableForm(); + } + + public function renderRow($row) + { + return $this->addSortPriorityButtons( + $this::row([ + $row->source_name, + $row->source_expression, + new Link( + $row->destination_field, + 'director/syncrule/editproperty', + [ + 'id' => $row->id, + 'rule_id' => $row->rule_id, + ] + ), + ]), + $row + ); + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Source name'), + $this->translate('Source field'), + $this->translate('Destination'), + $this->getSortPriorityTitle() + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['p' => 'sync_property'], + [ + 'id' => 'p.id', + 'rule_id' => 'p.rule_id', + 'rule_name' => 'r.rule_name', + 'source_id' => 'p.source_id', + 'source_name' => 's.source_name', + 'source_expression' => 'p.source_expression', + 'destination_field' => 'p.destination_field', + 'priority' => 'p.priority', + 'filter_expression' => 'p.filter_expression', + 'merge_policy' => 'p.merge_policy' + ] + )->join( + ['r' => 'sync_rule'], + 'r.id = p.rule_id', + [] + )->join( + ['s' => 'import_source'], + 's.id = p.source_id', + [] + )->where( + 'p.rule_id = ?', + $this->rule->get('id') + )->order('p.priority'); + } +} diff --git a/library/Director/Web/Table/SyncruleTable.php b/library/Director/Web/Table/SyncruleTable.php new file mode 100644 index 0000000..4a8e4e5 --- /dev/null +++ b/library/Director/Web/Table/SyncruleTable.php @@ -0,0 +1,67 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; + +class SyncruleTable extends ZfQueryBasedTable +{ + protected $searchColumns = [ + 'rule_name', + 'description', + ]; + + protected function assemble() + { + $this->getAttributes()->add('class', 'syncstate'); + parent::assemble(); + } + + public function renderRow($row) + { + $caption = [Link::create( + $row->rule_name, + 'director/syncrule', + ['id' => $row->id] + )]; + if ($row->description !== null) { + $caption[] = ': ' . $row->description; + } + + if ($row->sync_state === 'failing' && $row->last_error_message) { + $caption[] = ' (' . $row->last_error_message . ')'; + } + + $tr = $this::row([$caption, $row->object_type]); + $tr->getAttributes()->add('class', $row->sync_state); + + return $tr; + } + + public function getColumnsToBeRendered() + { + return [ + $this->translate('Rule name'), + $this->translate('Object type'), + ]; + } + + public function prepareQuery() + { + return $this->db()->select()->from( + ['s' => 'sync_rule'], + [ + 'id' => 's.id', + 'rule_name' => 's.rule_name', + 'sync_state' => 's.sync_state', + 'object_type' => 's.object_type', + 'update_policy' => 's.update_policy', + 'purge_existing' => 's.purge_existing', + 'filter_expression' => 's.filter_expression', + 'last_error_message' => 's.last_error_message', + 'description' => 's.description', + ] + )->order('rule_name'); + } +} diff --git a/library/Director/Web/Table/TableLoader.php b/library/Director/Web/Table/TableLoader.php new file mode 100644 index 0000000..f7e378b --- /dev/null +++ b/library/Director/Web/Table/TableLoader.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use Icinga\Exception\ProgrammingError; + +class TableLoader +{ + /** @return QuickTable */ + public static function load($name, Module $module = null) + { + if ($module === null) { + $basedir = Icinga::app()->getApplicationDir('tables'); + $ns = '\\Icinga\\Web\\Tables\\'; + } else { + $basedir = $module->getBaseDir() . '/application/tables'; + $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Tables\\'; + } + if (preg_match('~^[a-z0-9/]+$~i', $name)) { + $parts = preg_split('~/~', $name); + $class = ucfirst(array_pop($parts)) . 'Table'; + $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class); + if (file_exists($file)) { + require_once($file); + /** @var QuickTable $class */ + $class = $ns . $class; + return new $class(); + } + } + throw new ProgrammingError(sprintf('Cannot load %s (%s), no such table', $name, $file)); + } +} diff --git a/library/Director/Web/Table/TableWithBranchSupport.php b/library/Director/Web/Table/TableWithBranchSupport.php new file mode 100644 index 0000000..7c5b15c --- /dev/null +++ b/library/Director/Web/Table/TableWithBranchSupport.php @@ -0,0 +1,69 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Module\Director\Db\Branch\Branch; +use Ramsey\Uuid\UuidInterface; + +trait TableWithBranchSupport +{ + + /** @var UuidInterface|null */ + protected $branchUuid; + + /** + * Convenience method, only UUID is required + * + * @param Branch|null $branch + * @return $this + */ + public function setBranch(Branch $branch = null) + { + if ($branch && $branch->isBranch()) { + $this->setBranchUuid($branch->getUuid()); + } + + return $this; + } + + public function setBranchUuid(UuidInterface $uuid = null) + { + $this->branchUuid = $uuid; + + return $this; + } + + protected function branchifyColumns($columns) + { + $result = [ + 'uuid' => 'COALESCE(o.uuid, bo.uuid)' + ]; + $ignore = ['o.id', 'os.id', 'o.service_set_id', 'os.host_id']; + foreach ($columns as $alias => $column) { + if (substr($column, 0, 2) === 'o.' && ! in_array($column, $ignore)) { + // bo.column, o.column + $column = "COALESCE(b$column, $column)"; + } + if (substr($column, 0, 3) === 'os.' && ! in_array($column, $ignore)) { + // bo.column, o.column + $column = "COALESCE(b$column, $column)"; + } + + // Used in Service Tables: + if ($column === 'h.object_name' && $alias = 'host') { + $column = "COALESCE(bo.host, $column)"; + } + + $result[$alias] = $column; + } + + return $result; + } + + protected function stripSearchColumnAliases() + { + foreach ($this->searchColumns as &$column) { + $column = preg_replace('/^[a-z]+\./', '', $column); + } + } +} diff --git a/library/Director/Web/Table/TemplateUsageTable.php b/library/Director/Web/Table/TemplateUsageTable.php new file mode 100644 index 0000000..66e56ea --- /dev/null +++ b/library/Director/Web/Table/TemplateUsageTable.php @@ -0,0 +1,157 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Resolver\TemplateTree; +use gipfl\IcingaWeb2\Link; +use ipl\Html\Table; +use gipfl\Translation\TranslationHelper; + +class TemplateUsageTable extends Table +{ + use TranslationHelper; + + protected $defaultAttributes = ['class' => 'pivot']; + + protected $objectType; + + public function getTypes() + { + return [ + 'templates' => $this->translate('Templates'), + 'objects' => $this->translate('Objects'), + ]; + } + + protected function getTypeSummaryDefinitions() + { + return [ + 'templates' => $this->getSummaryLine('template'), + 'objects' => $this->getSummaryLine('object'), + ]; + } + + /** + * @param IcingaObject $template + * @return TemplateUsageTable + */ + public static function forTemplate(IcingaObject $template) + { + $type = ucfirst($template->getShortTableName()); + $class = __NAMESPACE__ . "\\${type}TemplateUsageTable"; + if (class_exists($class)) { + return new $class($template); + } else { + return new static($template); + } + } + + public function getColumnsToBeRendered() + { + return [ + '', + $this->translate('Direct'), + $this->translate('Indirect'), + $this->translate('Total') + ]; + } + + protected function __construct(IcingaObject $template) + { + + if ($template->get('object_type') !== 'template') { + throw new ProgrammingError( + 'TemplateUsageTable expects a template, got %s', + $template->get('object_type') + ); + } + + $this->objectType = $objectType = $template->getShortTableName(); + $types = $this->getTypes(); + $usage = $this->getUsageSummary($template); + + $used = false; + $rows = []; + foreach ($types as $type => $typeTitle) { + $tr = Table::tr(Table::th($typeTitle)); + foreach (['direct', 'indirect', 'total'] as $inheritance) { + $count = $usage->$inheritance->$type; + if (! $used && $count > 0) { + $used = true; + } + $tr->add( + Table::td( + Link::create( + $count, + "director/${objectType}template/$type", + [ + 'name' => $template->getObjectName(), + 'inheritance' => $inheritance + ] + ) + ) + ); + } + $rows[] = $tr; + } + + if ($used) { + $this->add($rows); + } else { + $this->add($this->translate('This template is not in use')); + } + } + + protected function getUsageSummary(IcingaObject $template) + { + $id = $template->getAutoincId(); + $connection = $template->getConnection(); + $db = $connection->getDbAdapter(); + $oType = $this->objectType; + $tree = new TemplateTree($oType, $connection); + $ids = $tree->listDescendantIdsFor($template); + if (empty($ids)) { + $ids = [0]; + } + + $baseQuery = $db->select()->from( + ['o' => 'icinga_' . $oType], + $this->getTypeSummaryDefinitions() + )->joinLeft( + ['oi' => "icinga_${oType}_inheritance"], + "oi.${oType}_id = o.id", + [] + ); + + $query = clone($baseQuery); + $direct = $db->fetchRow( + $query->where("oi.parent_${oType}_id = ?", $id) + ); + $query = clone($baseQuery); + $indirect = $db->fetchRow( + $query->where("oi.parent_${oType}_id IN (?)", $ids) + ); + //$indirect->templates = count($ids) - 1; + $total = []; + $types = array_keys($this->getTypes()); + foreach ($types as $type) { + $total[$type] = $direct->$type + $indirect->$type; + } + + return (object) [ + 'direct' => $direct, + 'indirect' => $indirect, + 'total' => (object) $total + ]; + } + + protected function getSummaryLine($type, $extra = null) + { + if ($extra !== null) { + $extra = " AND $extra"; + } + return "COALESCE(SUM(CASE WHEN o.object_type = '${type}'${extra} THEN 1 ELSE 0 END), 0)"; + } +} diff --git a/library/Director/Web/Table/TemplatesTable.php b/library/Director/Web/Table/TemplatesTable.php new file mode 100644 index 0000000..be195b2 --- /dev/null +++ b/library/Director/Web/Table/TemplatesTable.php @@ -0,0 +1,156 @@ +<?php + +namespace Icinga\Module\Director\Web\Table; + +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\IcingaObjectFilterHelper; +use Icinga\Module\Director\Objects\IcingaObject; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\IcingaWeb2\Table\Extension\MultiSelect; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; +use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer; +use Ramsey\Uuid\Uuid; +use Zend_Db_Select as ZfSelect; + +class TemplatesTable extends ZfQueryBasedTable implements FilterableByUsage +{ + use MultiSelect; + + protected $searchColumns = ['o.object_name']; + + private $type; + + public static function create($type, Db $db) + { + $table = new static($db); + $table->type = strtolower($type); + return $table; + } + + protected function assemble() + { + $type = $this->type; + $this->enableMultiSelect( + "director/${type}s/edittemplates", + "director/${type}template", + ['name'] + ); + } + + public function getType() + { + return $this->type; + } + + public function getColumnsToBeRendered() + { + return [$this->translate('Template Name')]; + } + + public function renderRow($row) + { + $name = $row->object_name; + $type = str_replace('_', '-', $this->getType()); + $caption = $row->is_used === 'y' ? $name : [ + $name, + Html::tag( + 'span', + ['style' => 'font-style: italic'], + $this->translate(' - not in use -') + ) + ]; + + $url = Url::fromPath("director/${type}template/usage", [ + 'name' => $name + ]); + + return $this::row([ + new Link($caption, $url), + [ + new Link(new Icon('plus'), "director/$type/add", [ + 'type' => 'object', + 'imports' => $name + ]), + new Link(new Icon('history'), "director/$type/history", [ + 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(), + ]) + ] + ]); + } + + public function filterTemplate( + IcingaObject $template, + $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT + ) { + IcingaObjectFilterHelper::filterByTemplate( + $this->getQuery(), + $template, + 'o', + $inheritance + ); + + return $this; + } + + public function showOnlyUsed() + { + $type = $this->getType(); + $this->getQuery()->where( + "(EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance" + . " WHERE parent_${type}_id = o.id))" + ); + } + + public function showOnlyUnUsed() + { + $type = $this->getType(); + $this->getQuery()->where( + "(NOT EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance" + . " WHERE parent_${type}_id = o.id))" + ); + } + + protected function applyRestrictions(ZfSelect $query) + { + $auth = Auth::getInstance(); + $type = $this->type; + $restrictions = $auth->getRestrictions("director/$type/template/filter-by-name"); + if (empty($restrictions)) { + return $query; + } + + $filter = Filter::matchAny(); + foreach ($restrictions as $restriction) { + $filter->addFilter(Filter::where('o.object_name', $restriction)); + } + + return FilterRenderer::applyToQuery($filter, $query); + } + + protected function prepareQuery() + { + $type = $this->getType(); + $used = "CASE WHEN EXISTS(SELECT 1 FROM icinga_${type}_inheritance oi" + . " WHERE oi.parent_${type}_id = o.id) THEN 'y' ELSE 'n' END"; + + $columns = [ + 'object_name' => 'o.object_name', + 'uuid' => 'o.uuid', + 'id' => 'o.id', + 'is_used' => $used, + ]; + $query = $this->db()->select()->from( + ['o' => "icinga_${type}"], + $columns + )->where( + "o.object_type = 'template'" + )->order('o.object_name'); + + return $this->applyRestrictions($query); + } +} diff --git a/library/Director/Web/Tabs/DataTabs.php b/library/Director/Web/Tabs/DataTabs.php new file mode 100644 index 0000000..ac29310 --- /dev/null +++ b/library/Director/Web/Tabs/DataTabs.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class DataTabs extends Tabs +{ + use TranslationHelper; + + public function __construct() + { + // We are not a BaseElement, not yet + $this->assemble(); + } + + protected function assemble() + { + $this->add('datafield', [ + 'label' => $this->translate('Data fields'), + 'url' => 'director/data/fields' + ])->add('datafieldcategory', [ + 'label' => $this->translate('Data field categories'), + 'url' => 'director/data/fieldcategories' + ])->add('datalist', [ + 'label' => $this->translate('Data lists'), + 'url' => 'director/data/lists' + ])->add('customvars', [ + 'label' => $this->translate('Custom Variables'), + 'url' => 'director/data/vars' + ]); + } +} diff --git a/library/Director/Web/Tabs/ImportTabs.php b/library/Director/Web/Tabs/ImportTabs.php new file mode 100644 index 0000000..e6c6807 --- /dev/null +++ b/library/Director/Web/Tabs/ImportTabs.php @@ -0,0 +1,30 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class ImportTabs extends Tabs +{ + use TranslationHelper; + + public function __construct() + { + $this->assemble(); + } + + protected function assemble() + { + $this->add('importsource', [ + 'label' => $this->translate('Import source'), + 'url' => 'director/importsources' + ])->add('syncrule', [ + 'label' => $this->translate('Sync rule'), + 'url' => 'director/syncrules' + ])->add('jobs', [ + 'label' => $this->translate('Jobs'), + 'url' => 'director/jobs' + ]); + } +} diff --git a/library/Director/Web/Tabs/ImportsourceTabs.php b/library/Director/Web/Tabs/ImportsourceTabs.php new file mode 100644 index 0000000..74dedb3 --- /dev/null +++ b/library/Director/Web/Tabs/ImportsourceTabs.php @@ -0,0 +1,58 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class ImportsourceTabs extends Tabs +{ + use TranslationHelper; + + protected $id; + + public function __construct($id = null) + { + $this->id = $id; + $this->assemble(); + } + + public function activateMainWithPostfix($postfix) + { + $mainTab = 'index'; + $tab = $this->get($mainTab); + $tab->setLabel($tab->getLabel() . ": $postfix"); + $this->activate($mainTab); + + return $this; + } + + protected function assemble() + { + if ($id = $this->id) { + $params = ['id' => $id]; + $this->add('index', [ + 'url' => 'director/importsource', + 'urlParams' => $params, + 'label' => $this->translate('Import source'), + ])->add('modifier', [ + 'url' => 'director/importsource/modifier', + 'urlParams' => ['source_id' => $id], + 'label' => $this->translate('Modifiers'), + ])->add('history', [ + 'url' => 'director/importsource/history', + 'urlParams' => $params, + 'label' => $this->translate('History'), + ])->add('preview', [ + 'url' => 'director/importsource/preview', + 'urlParams' => $params, + 'label' => $this->translate('Preview'), + ]); + } else { + $this->add('add', [ + 'url' => 'director/importsource/add', + 'label' => $this->translate('New import source'), + ])->activate('add'); + } + } +} diff --git a/library/Director/Web/Tabs/InfraTabs.php b/library/Director/Web/Tabs/InfraTabs.php new file mode 100644 index 0000000..8a65c4e --- /dev/null +++ b/library/Director/Web/Tabs/InfraTabs.php @@ -0,0 +1,49 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use Icinga\Authentication\Auth; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class InfraTabs extends Tabs +{ + use TranslationHelper; + + /** @var Auth */ + protected $auth; + + public function __construct(Auth $auth) + { + $this->auth = $auth; + // We are not a BaseElement, not yet + $this->assemble(); + } + + protected function assemble() + { + $auth = $this->auth; + + if ($auth->hasPermission('director/audit')) { + $this->add('activitylog', [ + 'label' => $this->translate('Activity Log'), + 'url' => 'director/config/activities' + ]); + } + + if ($auth->hasPermission('director/deploy')) { + $this->add('deploymentlog', [ + 'label' => $this->translate('Deployments'), + 'url' => 'director/config/deployments' + ]); + } + + if ($auth->hasPermission('director/admin')) { + $this->add('infrastructure', [ + 'label' => $this->translate('Infrastructure'), + 'url' => 'director/dashboard', + 'urlParams' => ['name' => 'infrastructure'] + ]); + } + } +} diff --git a/library/Director/Web/Tabs/MainTabs.php b/library/Director/Web/Tabs/MainTabs.php new file mode 100644 index 0000000..5ea2e9b --- /dev/null +++ b/library/Director/Web/Tabs/MainTabs.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Web\Widget\Daemon\BackgroundDaemonState; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Health; +use Icinga\Module\Director\Web\Widget\HealthCheckPluginOutput; + +class MainTabs extends Tabs +{ + use TranslationHelper; + + protected $auth; + + protected $dbResourceName; + + public function __construct(Auth $auth, $dbResourceName) + { + $this->auth = $auth; + $this->dbResourceName = $dbResourceName; + $this->add('main', [ + 'label' => $this->translate('Overview'), + 'url' => 'director' + ]); + if ($this->auth->hasPermission('director/admin')) { + $this->add('health', [ + 'label' => $this->translate('Health'), + 'url' => 'director/health' + ])->add('daemon', [ + 'label' => $this->translate('Daemon'), + 'url' => 'director/daemon' + ]); + } + } + + public function render() + { + if ($this->auth->hasPermission('director/admin')) { + if ($this->getActiveName() !== 'health') { + $state = $this->getHealthState(); + if ($state->isProblem()) { + $this->get('health')->setTagParams([ + 'class' => 'state-' . strtolower($state->getName()) + ]); + } + } + + if ($this->getActiveName() !== 'daemon') { + try { + $daemon = new BackgroundDaemonState(Db::fromResourceName($this->dbResourceName)); + if ($daemon->isRunning()) { + $state = 'ok'; + } else { + $state = 'critical'; + } + } catch (\Exception $e) { + $state = 'unknown'; + } + if ($state !== 'ok') { + $this->get('daemon')->setTagParams([ + 'class' => 'state-' . $state + ]); + } + } + } + + return parent::render(); + } + + /** + * @return \Icinga\Module\Director\CheckPlugin\PluginState + */ + protected function getHealthState() + { + $health = new Health(); + $health->setDbResourceName($this->dbResourceName); + $output = new HealthCheckPluginOutput($health); + + return $output->getState(); + } +} diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php new file mode 100644 index 0000000..cbd3f15 --- /dev/null +++ b/library/Director/Web/Tabs/ObjectTabs.php @@ -0,0 +1,160 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Objects\IcingaObject; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class ObjectTabs extends Tabs +{ + use TranslationHelper; + + /** @var string */ + private $type; + + /** @var Auth */ + private $auth; + + /** @var IcingaObject $object */ + private $object; + + private $allowedExternals = [ + 'apiuser', + 'endpoint' + ]; + + public function __construct($type, Auth $auth, IcingaObject $object = null) + { + $this->type = $type; + $this->auth = $auth; + $this->object = $object; + // We are not a BaseElement, not yet + $this->assemble(); + } + + protected function assemble() + { + if (null === $this->object) { + $this->addTabsForNewObject(); + } else { + $this->addTabsForExistingObject(); + } + } + + protected function addTabsForNewObject() + { + $type = $this->type; + $this->add('add', array( + 'url' => sprintf('director/%s/add', $type), + 'label' => sprintf($this->translate('Add %s'), ucfirst($type)), + )); + } + + protected function addTabsForExistingObject() + { + $type = $this->type; + $auth = $this->auth; + $object = $this->object; + $params = $object->getUrlParams(); + + if (! $object->isExternal() + || in_array($object->getShortTableName(), $this->allowedExternals) + ) { + $this->add('modify', array( + 'url' => sprintf('director/%s', $type), + 'urlParams' => $params, + 'label' => $this->translate(ucfirst($type)) + )); + } + if ($object->getShortTableName() === 'host') { + $this->add('services', [ + 'url' => 'director/host/services', + 'urlParams' => $params, + 'label' => $this->translate('Services') + ]); + } + + if ($auth->hasPermission('director/showconfig')) { + if ($object->getShortTableName() !== 'service' + || $object->get('service_set_id') === null + ) { + $this->add('render', array( + 'url' => sprintf('director/%s/render', $type), + 'urlParams' => $params, + 'label' => $this->translate('Preview'), + )); + } + } + + if ($auth->hasPermission('director/audit')) { + $this->add('history', array( + 'url' => sprintf('director/%s/history', $type), + 'urlParams' => $params, + 'label' => $this->translate('History') + )); + } + + if ($auth->hasPermission('director/admin') && $this->hasFields()) { + $this->add('fields', array( + 'url' => sprintf('director/%s/fields', $type), + 'urlParams' => $params, + 'label' => $this->translate('Fields') + )); + } + + // TODO: remove table check once we resolve all group types + if ($object->isGroup() && + ($object->getShortTableName() === 'hostgroup' || $object->getShortTableName() === 'servicegroup') + ) { + $this->add('membership', [ + 'url' => sprintf('director/%s/membership', $type), + 'urlParams' => $params, + 'label' => $this->translate('Members') + ]); + } + + if ($object->supportsRanges()) { + $this->add('ranges', [ + 'url' => "director/${type}/ranges", + 'urlParams' => $params, + 'label' => $this->translate('Ranges') + ]); + } + + if ($object->getShortTableName() === 'endpoint' + && $object->get('apiuser_id') + ) { + $this->add('inspect', [ + 'url' => 'director/inspect/types', + 'urlParams' => ['endpoint' => $object->getObjectName()], + 'label' => $this->translate('Inspect') + ]); + $this->add('packages', [ + 'url' => 'director/inspect/packages', + 'urlParams' => ['endpoint' => $object->getObjectName()], + 'label' => $this->translate('Packages') + ]); + } + + if ($object->getShortTableName() === 'host' && $auth->hasPermission('director/hosts')) { + $this->add('agent', [ + 'url' => 'director/host/agent', + 'urlParams' => $params, + 'label' => $this->translate('Agent') + ]); + } + } + + protected function hasFields() + { + if (! ($object = $this->object)) { + return false; + } + + return $object->hasBeenLoadedFromDb() + && $object->supportsFields() + && ($object->isTemplate() || $this->type === 'command'); + } +} diff --git a/library/Director/Web/Tabs/ObjectsTabs.php b/library/Director/Web/Tabs/ObjectsTabs.php new file mode 100644 index 0000000..4f9e5a8 --- /dev/null +++ b/library/Director/Web/Tabs/ObjectsTabs.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Objects\IcingaObject; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class ObjectsTabs extends Tabs +{ + use TranslationHelper; + + public function __construct($type, Auth $auth, $typeUrl) + { + $object = IcingaObject::createByType($type); + if ($object->isGroup()) { + $object = IcingaObject::createByType(substr($typeUrl, 0, -5)); + } + $shortName = $object->getShortTableName(); + + $plType = strtolower(preg_replace('/cys$/', 'cies', $shortName . 's')); + $plType = str_replace('_', '-', $plType); + if ($auth->hasPermission("director/${plType}")) { + $this->add('index', array( + 'url' => sprintf('director/%s', $plType), + 'label' => $this->translate(ucfirst($plType)), + )); + } + + if ($object->getShortTableName() === 'command') { + $this->add('external', array( + 'url' => sprintf('director/%s', strtolower($plType)), + 'urlParams' => ['type' => 'external_object'], + 'label' => $this->translate('External'), + )); + } + + if ($auth->hasPermission('director/admin') || ( + $object->getShortTableName() === 'notification' + && $auth->hasPermission('director/notifications') + ) || ( + $object->getShortTableName() === 'scheduled_downtime' + && $auth->hasPermission('director/scheduled-downtimes') + )) { + if ($object->supportsApplyRules()) { + $this->add('applyrules', array( + 'url' => sprintf('director/%s/applyrules', $plType), + 'label' => $this->translate('Apply') + )); + } + } + + if ($auth->hasPermission('director/admin') && $type !== 'zone') { + if ($object->supportsImports()) { + $this->add('templates', array( + 'url' => sprintf('director/%s/templates', $plType), + 'label' => $this->translate('Templates'), + )); + } + + if ($object->supportsGroups()) { + $this->add('groups', array( + 'url' => sprintf('director/%sgroups', $typeUrl), + 'label' => $this->translate('Groups') + )); + } + } + + if ($auth->hasPermission('director/admin')) { + if ($object->supportsChoices()) { + $this->add('choices', array( + 'url' => sprintf('director/templatechoices/%s', $shortName), + 'label' => $this->translate('Choices') + )); + } + } + if ($object->supportsSets() && $auth->hasPermission("director/${typeUrl}sets")) { + $this->add('sets', array( + 'url' => sprintf('director/%s/sets', $plType), + 'label' => $this->translate('Sets') + )); + } + } +} diff --git a/library/Director/Web/Tabs/SyncRuleTabs.php b/library/Director/Web/Tabs/SyncRuleTabs.php new file mode 100644 index 0000000..d64ff81 --- /dev/null +++ b/library/Director/Web/Tabs/SyncRuleTabs.php @@ -0,0 +1,54 @@ +<?php + +namespace Icinga\Module\Director\Web\Tabs; + +use Icinga\Module\Director\Objects\SyncRule; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\Tabs; + +class SyncRuleTabs extends Tabs +{ + use TranslationHelper; + + protected $rule; + + public function __construct(SyncRule $rule = null) + { + $this->rule = $rule; + // We are not a BaseElement, not yet + $this->assemble(); + } + + protected function assemble() + { + if ($this->rule) { + $id = $this->rule->get('id'); + $this->add('show', [ + 'url' => 'director/syncrule', + 'urlParams' => ['id' => $id], + 'label' => $this->translate('Sync rule'), + ])->add('preview', [ + 'url' => 'director/syncrule/preview', + 'urlParams' => ['id' => $id], + 'label' => $this->translate('Preview'), + ])->add('edit', [ + 'url' => 'director/syncrule/edit', + 'urlParams' => ['id' => $id], + 'label' => $this->translate('Modify'), + ])->add('property', [ + 'label' => $this->translate('Properties'), + 'url' => 'director/syncrule/property', + 'urlParams' => ['rule_id' => $id] + ])->add('history', [ + 'label' => $this->translate('History'), + 'url' => 'director/syncrule/history', + 'urlParams' => ['id' => $id] + ]); + } else { + $this->add('add', [ + 'url' => 'director/syncrule/add', + 'label' => $this->translate('Sync rule'), + ]); + } + } +} diff --git a/library/Director/Web/Tree/InspectTreeRenderer.php b/library/Director/Web/Tree/InspectTreeRenderer.php new file mode 100644 index 0000000..54a177f --- /dev/null +++ b/library/Director/Web/Tree/InspectTreeRenderer.php @@ -0,0 +1,97 @@ +<?php + +namespace Icinga\Module\Director\Web\Tree; + +use Icinga\Module\Director\Objects\IcingaEndpoint; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; + +class InspectTreeRenderer extends BaseHtmlElement +{ + use TranslationHelper; + + protected $tag = 'ul'; + + protected $defaultAttributes = [ + 'class' => 'tree', + 'data-base-target' => '_next', + ]; + + protected $tree; + + /** @var IcingaEndpoint */ + protected $endpoint; + + public function __construct(IcingaEndpoint $endpoint) + { + $this->endpoint = $endpoint; + } + + protected function getNodes() + { + $rootNodes = array(); + $types = $this->endpoint->api()->getTypes(); + foreach ($types as $name => $type) { + if (property_exists($type, 'base')) { + $base = $type->base; + if (! property_exists($types[$base], 'children')) { + $types[$base]->children = array(); + } + + $types[$base]->children[$name] = $type; + } else { + $rootNodes[$name] = $type; + } + } + + return $rootNodes; + } + + public function assemble() + { + $this->add($this->renderNodes($this->getNodes())); + } + + protected function renderNodes($nodes, $showLinks = false, $level = 0) + { + $result = []; + foreach ($nodes as $child) { + $result[] = $this->renderNode($child, $showLinks, $level + 1); + } + + if ($level === 0) { + return $result; + } else { + return Html::tag('ul', null, $result); + } + } + + protected function renderNode($node, $forceLinks = false, $level = 0) + { + $name = $node->name; + $showLinks = $forceLinks || $name === 'ConfigObject'; + $hasChildren = property_exists($node, 'children'); + $li = Html::tag('li'); + if (! $hasChildren) { + $li->getAttributes()->add('class', 'collapsed'); + } + + if ($hasChildren) { + $li->add(Html::tag('span', ['class' => 'handle'])); + } + + $class = $node->abstract ? 'icon-sitemap' : 'icon-doc-text'; + $li->add(Link::create($name, 'director/inspect/type', [ + 'endpoint' => $this->endpoint->getObjectName(), + 'type' => $name + ], ['class' => $class])); + + if ($hasChildren) { + $li->add($this->renderNodes($node->children, $showLinks, $level + 1)); + } + + return $li; + } +} diff --git a/library/Director/Web/Tree/TemplateTreeRenderer.php b/library/Director/Web/Tree/TemplateTreeRenderer.php new file mode 100644 index 0000000..e238ded --- /dev/null +++ b/library/Director/Web/Tree/TemplateTreeRenderer.php @@ -0,0 +1,91 @@ +<?php + +namespace Icinga\Module\Director\Web\Tree; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Resolver\TemplateTree; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\ControlsAndContent; + +class TemplateTreeRenderer extends BaseHtmlElement +{ + use TranslationHelper; + + protected $tag = 'ul'; + + protected $defaultAttributes = [ + 'class' => 'tree', + 'data-base-target' => '_next', + ]; + + protected $tree; + + public function __construct(TemplateTree $tree) + { + $this->tree = $tree; + } + + public static function showType($type, ControlsAndContent $controller, Db $db) + { + $controller->content()->add( + new static(new TemplateTree($type, $db)) + ); + } + + public function renderContent() + { + $this->add( + $this->dumpTree( + array( + 'name' => $this->translate('Templates'), + 'children' => $this->tree->getTree() + ) + ) + ); + + return parent::renderContent(); + } + + protected function dumpTree($tree, $level = 0) + { + $hasChildren = ! empty($tree['children']); + $type = $this->tree->getType(); + + $li = Html::tag('li'); + if (! $hasChildren) { + $li->getAttributes()->add('class', 'collapsed'); + } + + if ($hasChildren) { + $li->add(Html::tag('span', ['class' => 'handle'])); + } + + if ($level === 0) { + $li->add(Html::tag('a', [ + 'name' => $tree['name'], + 'class' => 'icon-globe' + ], $tree['name'])); + } else { + $li->add(Link::create( + $tree['name'], + "director/${type}template/usage", + array('name' => $tree['name']), + array('class' => 'icon-' .$type) + )); + } + + if ($hasChildren) { + $li->add( + $ul = Html::tag('ul') + ); + foreach ($tree['children'] as $child) { + $ul->add($this->dumpTree($child, $level + 1)); + } + } + + return $li; + } +} diff --git a/library/Director/Web/Widget/AbstractList.php b/library/Director/Web/Widget/AbstractList.php new file mode 100644 index 0000000..ad1b9e3 --- /dev/null +++ b/library/Director/Web/Widget/AbstractList.php @@ -0,0 +1,40 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; + +class AbstractList extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + /** + * AbstractList constructor. + * @param array $items + * @param null $attributes + */ + public function __construct(array $items = [], $attributes = null) + { + foreach ($items as $item) { + $this->addItem($item); + } + + if ($attributes !== null) { + $this->addAttributes($attributes); + } + } + + /** + * @param Html|array|string $content + * @param Attributes|array $attributes + * + * @return $this + */ + public function addItem($content, $attributes = null) + { + return $this->add(HtmlElement::create('li', $attributes, $content)); + } +} diff --git a/library/Director/Web/Widget/ActivityLogInfo.php b/library/Director/Web/Widget/ActivityLogInfo.php new file mode 100644 index 0000000..8454b26 --- /dev/null +++ b/library/Director/Web/Widget/ActivityLogInfo.php @@ -0,0 +1,634 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Json\JsonString; +use Icinga\Module\Director\Objects\DirectorActivityLog; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use Icinga\Date\DateFormatter; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Forms\RestoreObjectForm; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\IcingaServiceSet; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; +use gipfl\IcingaWeb2\Widget\NameValueTable; +use gipfl\IcingaWeb2\Widget\Tabs; + +class ActivityLogInfo extends HtmlDocument +{ + use TranslationHelper; + + protected $defaultTab; + + /** @var Db */ + protected $db; + + /** @var string */ + protected $type; + + /** @var string */ + protected $typeName; + + /** @var string */ + protected $name; + + protected $entry; + + protected $oldProperties; + + protected $newProperties; + + protected $oldObject; + + /** @var Tabs */ + protected $tabs; + + /** @var int */ + protected $id; + + public function __construct(Db $db, $type = null, $name = null) + { + $this->db = $db; + if ($type !== null) { + $this->setType($type); + } + $this->name = $name; + } + + public function setType($type) + { + $this->type = $type; + $this->typeName = $this->translate( + ucfirst(preg_replace('/^icinga_/', '', $type)) // really? + ); + + return $this; + } + + /** + * @param Url $url + * @return HtmlElement + * @throws \Icinga\Exception\IcingaException + */ + public function getPagination(Url $url) + { + /** @var Url $url */ + $url = $url->without('checksum')->without('show'); + $div = Html::tag('div', [ + 'class' => 'pagination-control', + 'style' => 'float: right; width: 5em' + ]); + + $ul = Html::tag('ul', ['class' => 'nav tab-nav']); + $li = Html::tag('li', ['class' => 'nav-item']); + $ul->add($li); + $neighbors = $this->getNeighbors(); + $iconLeft = new Icon('angle-double-left'); + $iconRight = new Icon('angle-double-right'); + if ($neighbors->prev) { + $li->add(new Link($iconLeft, $url->with('id', $neighbors->prev))); + } else { + $li->add(Html::tag('span', ['class' => 'disabled'], $iconLeft)); + } + + $li = Html::tag('li', ['class' => 'nav-item']); + $ul->add($li); + if ($neighbors->next) { + $li->add(new Link($iconRight, $url->with('id', $neighbors->next))); + } else { + $li->add(Html::tag('span', ['class' => 'disabled'], $iconRight)); + } + + return $div->add($ul); + } + + /** + * @param $tabName + * @return $this + * @throws \Icinga\Exception\Http\HttpNotFoundException + * @throws \Icinga\Exception\IcingaException + */ + public function showTab($tabName) + { + if ($tabName === null) { + $tabName = $this->defaultTab; + } + + $this->getTabs()->activate($tabName); + $this->add($this->getInfoTable()); + if ($tabName === 'old') { + // $title = sprintf('%s former config', $this->entry->object_name); + $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->emptyConfig()); + } elseif ($tabName === 'new') { + // $title = sprintf('%s new config', $this->entry->object_name); + $diffs = IcingaConfigDiff::getDiffs($this->emptyConfig(), $this->newConfig()); + } else { + $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->newConfig()); + } + + $this->addDiffs($diffs); + + return $this; + } + + protected function emptyConfig() + { + return new IcingaConfig($this->db); + } + + /** + * @param $diffs + * @throws \Icinga\Exception\IcingaException + */ + protected function addDiffs($diffs) + { + foreach ($diffs as $file => $diff) { + $this->add(Html::tag('h3', null, $file))->add($diff); + } + } + + /** + * @return RestoreObjectForm + * @throws \Icinga\Exception\IcingaException + */ + protected function getRestoreForm() + { + return RestoreObjectForm::load() + ->setDb($this->db) + ->setObject($this->oldObject()) + ->handleRequest(); + } + + public function setChecksum($checksum) + { + if ($checksum !== null) { + $this->entry = $this->db->fetchActivityLogEntry($checksum); + $this->id = (int) $this->entry->id; + } + + return $this; + } + + public function setId($id) + { + if ($id !== null) { + $this->entry = $this->db->fetchActivityLogEntryById($id); + $this->id = (int) $id; + } + + return $this; + } + + public function getNeighbors() + { + return $this->db->getActivitylogNeighbors( + $this->id, + $this->type, + $this->name + ); + } + + public function getCurrentObject() + { + return IcingaObject::loadByType( + $this->type, + $this->name, + $this->db + ); + } + + /** + * @return bool + * @deprecated No longer used? + */ + public function objectStillExists() + { + return IcingaObject::existsByType( + $this->type, + $this->objectKey(), + $this->db + ); + } + + protected function oldProperties() + { + if ($this->oldProperties === null) { + if (property_exists($this->entry, 'old_properties')) { + $this->oldProperties = JsonString::decodeOptional($this->entry->old_properties); + } + if ($this->oldProperties === null) { + $this->oldProperties = new \stdClass; + } + } + + return $this->oldProperties; + } + + protected function newProperties() + { + if ($this->newProperties === null) { + if (property_exists($this->entry, 'new_properties')) { + $this->newProperties = JsonString::decodeOptional($this->entry->new_properties); + } + if ($this->newProperties === null) { + $this->newProperties = new \stdClass; + } + } + + return $this->newProperties; + } + + protected function getEntryProperty($key) + { + $entry = $this->entry; + + if (property_exists($entry, $key)) { + return $entry->{$key}; + } elseif (property_exists($this->newProperties(), $key)) { + return $this->newProperties->{$key}; + } elseif (property_exists($this->oldProperties(), $key)) { + return $this->oldProperties->{$key}; + } else { + return null; + } + } + + protected function objectLinkParams() + { + $entry = $this->entry; + + $params = ['name' => $entry->object_name]; + + if ($entry->object_type === 'icinga_service') { + if (($set = $this->getEntryProperty('service_set')) !== null) { + $params['set'] = $set; + return $params; + } elseif (($host = $this->getEntryProperty('host')) !== null) { + $params['host'] = $host; + return $params; + } else { + return $params; + } + } elseif ($entry->object_type === 'icinga_service_set') { + return $params; + } else { + return $params; + } + } + + protected function getActionExtraHtml() + { + $entry = $this->entry; + + $info = ''; + $host = null; + + if ($entry->object_type === 'icinga_service') { + if (($set = $this->getEntryProperty('service_set')) !== null) { + $info = Html::sprintf( + '%s "%s"', + $this->translate('on service set'), + Link::create( + $set, + 'director/serviceset', + ['name' => $set], + ['data-base-target' => '_next'] + ) + ); + } else { + $host = $this->getEntryProperty('host'); + } + } elseif ($entry->object_type === 'icinga_service_set') { + $host = $this->getEntryProperty('host'); + } + + if ($host !== null) { + $info = Html::sprintf( + '%s "%s"', + $this->translate('on host'), + Link::create( + $host, + 'director/host', + ['name' => $host], + ['data-base-target' => '_next'] + ) + ); + } + + return $info; + } + + /** + * @return array + * @deprecated No longer used? + */ + protected function objectKey() + { + $entry = $this->entry; + if ($entry->object_type === 'icinga_service' || $entry->object_type === 'icinga_service_set') { + // TODO: this is not correct. Activity needs to get (multi) key support + return ['name' => $entry->object_name]; + } + + return $entry->object_name; + } + + /** + * @param Url|null $url + * @return Tabs + */ + public function getTabs(Url $url = null) + { + if ($this->tabs === null) { + $this->tabs = $this->createTabs($url); + } + + return $this->tabs; + } + + /** + * @param Url $url + * @return Tabs + */ + public function createTabs(Url $url) + { + $entry = $this->entry; + $tabs = new Tabs(); + if ($entry->action_name === DirectorActivityLog::ACTION_MODIFY) { + $tabs->add('diff', [ + 'label' => $this->translate('Diff'), + 'url' => $url->without('show')->with('id', $entry->id) + ]); + + $this->defaultTab = 'diff'; + } + + if (in_array($entry->action_name, [ + DirectorActivityLog::ACTION_CREATE, + DirectorActivityLog::ACTION_MODIFY, + ])) { + $tabs->add('new', [ + 'label' => $this->translate('New object'), + 'url' => $url->with(['id' => $entry->id, 'show' => 'new']) + ]); + + if ($this->defaultTab === null) { + $this->defaultTab = 'new'; + } + } + + if (in_array($entry->action_name, [ + DirectorActivityLog::ACTION_DELETE, + DirectorActivityLog::ACTION_MODIFY, + ])) { + $tabs->add('old', [ + 'label' => $this->translate('Former object'), + 'url' => $url->with(['id' => $entry->id, 'show' => 'old']) + ]); + + if ($this->defaultTab === null) { + $this->defaultTab = 'old'; + } + } + + return $tabs; + } + + /** + * @return IcingaObject + * @throws \Icinga\Exception\IcingaException + */ + protected function oldObject() + { + if ($this->oldObject === null) { + $this->oldObject = $this->createObject( + $this->entry->object_type, + $this->entry->old_properties + ); + } + + return $this->oldObject; + } + + /** + * @return IcingaObject + * @throws \Icinga\Exception\IcingaException + */ + protected function newObject() + { + return $this->createObject( + $this->entry->object_type, + $this->entry->new_properties + ); + } + + protected function objectToConfig(IcingaObject $object) + { + if ($object instanceof IcingaService) { + return $this->previewService($object); + } else { + return $object->toSingleIcingaConfig(); + } + } + + protected function previewService(IcingaService $service) + { + if (($set = $service->get('service_set')) !== null) { + // simulate rendering of service in set + $set = IcingaServiceSet::load($set, $this->db); + + $service->set('service_set_id', null); + if (($assign = $set->get('assign_filter')) !== null) { + $service->set('object_type', 'apply'); + $service->set('assign_filter', $assign); + } + } + + return $service->toSingleIcingaConfig(); + } + + /** + * @return IcingaConfig + * @throws \Icinga\Exception\IcingaException + */ + protected function newConfig() + { + return $this->objectToConfig($this->newObject()); + } + + /** + * @return IcingaConfig + * @throws \Icinga\Exception\IcingaException + */ + protected function oldConfig() + { + return $this->objectToConfig($this->oldObject()); + } + + protected function getLinkToObject() + { + // TODO: This logic is redundant and should be centralized + $entry = $this->entry; + $name = $entry->object_name; + $controller = preg_replace('/^icinga_/', '', $entry->object_type); + + if ($controller === 'service_set') { + $controller = 'serviceset'; + } elseif ($controller === 'scheduled_downtime') { + $controller = 'scheduled-downtime'; + } + + return Link::create( + $name, + 'director/' . $controller, + $this->objectLinkParams(), + ['data-base-target' => '_next'] + ); + } + + /** + * @return NameValueTable + * @throws \Icinga\Exception\IcingaException + */ + public function getInfoTable() + { + $entry = $this->entry; + $table = new NameValueTable(); + $table->addNameValuePairs([ + $this->translate('Author') => $entry->author, + $this->translate('Date') => DateFormatter::formatDateTime( + $entry->change_time_ts + ), + + ]); + if (null === $this->name) { + $table->addNameValueRow( + $this->translate('Action'), + Html::sprintf( + '%s %s "%s" %s', + $entry->action_name, + $entry->object_type, + $this->getLinkToObject(), + $this->getActionExtraHtml() + ) + ); + } else { + $table->addNameValueRow( + $this->translate('Action'), + $entry->action_name + ); + } + + if ($comment = $this->getOptionalRangeComment()) { + $table->addNameValueRow( + $this->translate('Remark'), + $comment + ); + } + + if ($this->hasBeenEnabled()) { + $table->addNameValueRow( + $this->translate('Rendering'), + $this->translate('This object has been enabled') + ); + } elseif ($this->hasBeenDisabled()) { + $table->addNameValueRow( + $this->translate('Rendering'), + $this->translate('This object has been disabled') + ); + } + + $table->addNameValueRow( + $this->translate('Checksum'), + $entry->checksum + ); + if ($this->entry->old_properties) { + $table->addNameValueRow( + $this->translate('Actions'), + $this->getRestoreForm() + ); + } + + return $table; + } + + public function hasBeenEnabled() + { + return false; + } + + public function hasBeenDisabled() + { + return false; + } + + /** + * @return string + * @throws ProgrammingError + */ + public function getTitle() + { + switch ($this->entry->action_name) { + case DirectorActivityLog::ACTION_CREATE: + $msg = $this->translate('%s "%s" has been created'); + break; + case DirectorActivityLog::ACTION_DELETE: + $msg = $this->translate('%s "%s" has been deleted'); + break; + case DirectorActivityLog::ACTION_MODIFY: + $msg = $this->translate('%s "%s" has been modified'); + break; + default: + throw new ProgrammingError( + 'Unable to deal with "%s" activity', + $this->entry->action_name + ); + } + + return sprintf($msg, $this->typeName, $this->entry->object_name); + } + + protected function getOptionalRangeComment() + { + if ($this->id) { + $db = $this->db->getDbAdapter(); + return $db->fetchOne( + $db->select() + ->from('director_activity_log_remark', 'remark') + ->where('first_related_activity <= ?', $this->id) + ->where('last_related_activity >= ?', $this->id) + ); + } + + return null; + } + + /** + * @param $type + * @param $props + * @return IcingaObject + * @throws \Icinga\Exception\IcingaException + */ + protected function createObject($type, $props) + { + $props = json_decode($props); + $newProps = ['object_name' => $props->object_name]; + if (property_exists($props, 'object_type')) { + $newProps['object_type'] = $props->object_type; + } + + return IcingaObject::createByType( + $type, + $newProps, + $this->db + )->setProperties((array) $props); + } +} diff --git a/library/Director/Web/Widget/AdditionalTableActions.php b/library/Director/Web/Widget/AdditionalTableActions.php new file mode 100644 index 0000000..978f399 --- /dev/null +++ b/library/Director/Web/Widget/AdditionalTableActions.php @@ -0,0 +1,158 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use gipfl\IcingaWeb2\Url; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Web\Table\FilterableByUsage; + +class AdditionalTableActions +{ + use TranslationHelper; + + /** @var Auth */ + protected $auth; + + /** @var Url */ + protected $url; + + /** @var ZfQueryBasedTable */ + protected $table; + + public function __construct(Auth $auth, Url $url, ZfQueryBasedTable $table) + { + $this->auth = $auth; + $this->url = $url; + $this->table = $table; + } + + public function appendTo(HtmlDocument $parent) + { + $links = []; + if ($this->hasPermission('director/admin')) { + $links[] = $this->createDownloadJsonLink(); + } + if ($this->hasPermission('director/showsql')) { + $links[] = $this->createShowSqlToggle(); + } + + if ($this->table instanceof FilterableByUsage) { + $parent->add($this->showUsageFilter($this->table)); + } + + if (! empty($links)) { + $parent->add($this->moreOptions($links)); + } + + return $this; + } + + protected function createDownloadJsonLink() + { + return Link::create( + $this->translate('Download as JSON'), + $this->url->with('format', 'json'), + null, + ['target' => '_blank'] + ); + } + + protected function createShowSqlToggle() + { + if ($this->url->getParam('format') === 'sql') { + $link = Link::create( + $this->translate('Hide SQL'), + $this->url->without('format') + ); + } else { + $link = Link::create( + $this->translate('Show SQL'), + $this->url->with('format', 'sql') + ); + } + + return $link; + } + + protected function showUsageFilter(FilterableByUsage $table) + { + $active = $this->url->getParam('usage', 'all'); + $links = [ + Link::create($this->translate('all'), $this->url->without('usage')), + Link::create($this->translate('used'), $this->url->with('usage', 'used')), + Link::create($this->translate('unused'), $this->url->with('usage', 'unused')), + ]; + + if ($active === 'used') { + $table->showOnlyUsed(); + } elseif ($active === 'unused') { + $table->showOnlyUnUsed(); + } + + $options = $this->ul( + $this->li([ + Link::create( + sprintf($this->translate('Usage (%s)'), $active), + '#', + null, + [ + 'class' => 'icon-sitemap' + ] + ), + $subUl = Html::tag('ul') + ]), + ['class' => 'nav'] + ); + + foreach ($links as $link) { + $subUl->add($this->li($link)); + } + + return $options; + } + + protected function moreOptions($links) + { + $options = $this->ul( + $this->li([ + // TODO: extend link for dropdown-toggle from Web 2, doesn't + // seem to work: [..], null, ['class' => 'dropdown-toggle'] + Link::create(Icon::create('down-open'), '#'), + $subUl = Html::tag('ul') + ]), + ['class' => 'nav'] + ); + + foreach ($links as $link) { + $subUl->add($this->li($link)); + } + + return $options; + } + + protected function ulLi($content) + { + return $this->ul($this->li($content)); + } + + protected function ul($content, $attributes = null) + { + return Html::tag('ul', $attributes, $content); + } + + protected function li($content) + { + return Html::tag('li', null, $content); + } + + protected function hasPermission($permission) + { + return $this->auth->hasPermission($permission); + } +} diff --git a/library/Director/Web/Widget/BackgroundDaemonDetails.php b/library/Director/Web/Widget/BackgroundDaemonDetails.php new file mode 100644 index 0000000..b4c33dd --- /dev/null +++ b/library/Director/Web/Widget/BackgroundDaemonDetails.php @@ -0,0 +1,131 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Widget\NameValueTable; +use gipfl\Translation\TranslationHelper; +use gipfl\Web\Widget\Hint; +use Icinga\Date\DateFormatter; +use Icinga\Module\Director\Daemon\RunningDaemonInfo; +use Icinga\Util\Format; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\Table; + +class BackgroundDaemonDetails extends BaseHtmlElement +{ + use TranslationHelper; + + protected $tag = 'div'; + + /** @var RunningDaemonInfo */ + protected $info; + + /** @var \stdClass TODO: get rid of this */ + protected $daemon; + + public function __construct(RunningDaemonInfo $info, $daemon) + { + $this->info = $info; + $this->daemon = $daemon; + } + + protected function assemble() + { + $info = $this->info; + if ($info->hasBeenStopped()) { + $this->add(Hint::error(Html::sprintf( + $this->translate( + 'Daemon has been stopped %s, was running with PID %s as %s@%s' + ), + // $info->getHexUuid(), + $this->timeAgo($info->getTimestampStopped() / 1000), + Html::tag('strong', (string) $info->getPid()), + Html::tag('strong', $info->getUsername()), + Html::tag('strong', $info->getFqdn()) + ))); + } elseif ($info->isOutdated()) { + $this->add(Hint::error(Html::sprintf( + $this->translate( + 'Daemon keep-alive is outdated, was last seen running with PID %s as %s@%s %s' + ), + // $info->getHexUuid(), + Html::tag('strong', (string) $info->getPid()), + Html::tag('strong', $info->getUsername()), + Html::tag('strong', $info->getFqdn()), + $this->timeAgo($info->getLastUpdate() / 1000) + ))); + } else { + $this->add(Hint::ok(Html::sprintf( + $this->translate( + 'Daemon is running with PID %s as %s@%s, last refresh happened %s' + ), + // $info->getHexUuid(), + Html::tag('strong', (string)$info->getPid()), + Html::tag('strong', $info->getUsername()), + Html::tag('strong', $info->getFqdn()), + $this->timeAgo($info->getLastUpdate() / 1000) + ))); + $details = new NameValueTable(); + $details->addNameValuePairs([ + $this->translate('Startup Time') => DateFormatter::formatDateTime($info->getTimestampStarted() / 1000), + $this->translate('PID') => $info->getPid(), + $this->translate('Username') => $info->getUsername(), + $this->translate('FQDN') => $info->getFqdn(), + $this->translate('Running with systemd') => $info->isRunningWithSystemd() + ? $this->translate('yes') + : $this->translate('no'), + $this->translate('Binary') => $info->getBinaryPath() + . ($info->binaryRealpathDiffers() ? ' -> ' . $info->getBinaryRealpath() : ''), + $this->translate('PHP Binary') => $info->getPhpBinaryPath() + . ($info->phpBinaryRealpathDiffers() ? ' -> ' . $info->getPhpBinaryRealpath() : ''), + $this->translate('PHP Version') => $info->getPhpVersion(), + $this->translate('PHP Integer') => $info->has64bitIntegers() + ? '64bit' + : Html::sprintf( + '%sbit (%s)', + $info->getPhpIntegerSize() * 8, + Html::tag('span', ['class' => 'error'], $this->translate('unsupported')) + ), + ]); + $this->add($details); + + $this->add(Html::tag('h2', $this->translate('Process List'))); + if (\is_string($this->daemon->process_info)) { + // from DB: + $processes = \json_decode($this->daemon->process_info); + } else { + // via RPC: + $processes = $this->daemon->process_info; + } + $table = new Table(); + $table->add(Html::tag('thead', Html::tag('tr', Html::wrapEach([ + 'PID', + 'Command', + 'Memory' + ], 'th')))); + $table->setAttribute('class', 'common-table'); + foreach ($processes as $pid => $process) { + $table->add($table::row([ + [ + Icon::create($process->running ? 'ok' : 'warning-empty'), + ' ', + $pid + ], + Html::tag('pre', $process->command), + $process->memory === false ? 'n/a' : Format::bytes($process->memory->rss) + ])); + } + $this->add($table); + } + } + + protected function timeAgo($time) + { + return Html::tag('span', [ + 'class' => 'time-ago', + 'title' => DateFormatter::formatDateTime($time) + ], DateFormatter::timeAgo($time)); + } +} diff --git a/library/Director/Web/Widget/BranchedObjectHint.php b/library/Director/Web/Widget/BranchedObjectHint.php new file mode 100644 index 0000000..ec16094 --- /dev/null +++ b/library/Director/Web/Widget/BranchedObjectHint.php @@ -0,0 +1,69 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\Widget\Hint; +use Icinga\Authentication\Auth; +use Icinga\Exception\NotFoundError; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchedObject; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; + +class BranchedObjectHint extends HtmlDocument +{ + use TranslationHelper; + + public function __construct(Branch $branch, Auth $auth, BranchedObject $object = null) + { + if (! $branch->isBranch()) { + return; + } + $hook = Branch::requireHook(); + + $name = $branch->getName(); + if (substr($name, 0, 1) === '/') { + $label = $this->translate('this configuration branch'); + } else { + $label = $name; + } + $link = $hook->linkToBranch($branch, $auth, $label); + if ($object === null) { + $this->add(Hint::info(Html::sprintf($this->translate( + 'This object will be created in %s. It will not be part of any deployment' + . ' unless being merged' + ), $link))); + return; + } + + if (! $object->hasBeenTouchedByBranch()) { + $this->add(Hint::info(Html::sprintf($this->translate( + 'Your changes will be stored in %s. The\'ll not be part of any deployment' + . ' unless being merged' + ), $link))); + return; + } + + if ($object->hasBeenDeletedByBranch()) { + throw new NotFoundError('No such object available'); + // Alternative, requires hiding other actions: + // $this->add(Hint::info(Html::sprintf( + // $this->translate('This object has been deleted in %s'), + // $link + // ))); + } elseif ($object->hasBeenCreatedByBranch()) { + $this->add(Hint::info(Html::sprintf( + $this->translate('This object has been created in %s'), + $link + ))); + } else { + $this->add(Hint::info(Html::sprintf( + $this->translate('This object has modifications visible only in %s'), + // TODO: Also link to object modifications + // $hook->linkToBranchedObject($this->translate('modifications'), $branch, $object, $auth), + $link + ))); + } + } +} diff --git a/library/Director/Web/Widget/BranchedObjectsHint.php b/library/Director/Web/Widget/BranchedObjectsHint.php new file mode 100644 index 0000000..d689178 --- /dev/null +++ b/library/Director/Web/Widget/BranchedObjectsHint.php @@ -0,0 +1,27 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\Widget\Hint; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db\Branch\Branch; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; + +class BranchedObjectsHint extends HtmlDocument +{ + use TranslationHelper; + + public function __construct(Branch $branch, Auth $auth) + { + if (! $branch->isBranch()) { + return; + } + $hook = Branch::requireHook(); + $this->add(Hint::info(Html::sprintf( + $this->translate('Showing a branched view, with potential changes being visible only in this %s'), + $hook->linkToBranch($branch, $auth, $this->translate('configuration branch')) + ))); + } +} diff --git a/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php new file mode 100644 index 0000000..03e76b2 --- /dev/null +++ b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php @@ -0,0 +1,57 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget\Daemon; + +use Icinga\Module\Director\Daemon\RunningDaemonInfo; +use Icinga\Module\Director\Db; + +class BackgroundDaemonState +{ + protected $db; + + /** @var RunningDaemonInfo[] */ + protected $instances; + + public function __construct(Db $db) + { + $this->db = $db; + } + + public function isRunning() + { + foreach ($this->getInstances() as $instance) { + if ($instance->isRunning()) { + return true; + } + } + + return false; + } + + protected function getInstances() + { + if ($this->instances === null) { + $this->instances = $this->fetchInfo(); + } + + return $this->instances; + } + + /** + * @return RunningDaemonInfo[] + */ + protected function fetchInfo() + { + $db = $this->db->getDbAdapter(); + $daemons = $db->fetchAll( + $db->select()->from('director_daemon_info')->order('fqdn')->order('username')->order('pid') + ); + + $result = []; + foreach ($daemons as $info) { + $result[] = new RunningDaemonInfo($info); + } + + return $result; + } +} diff --git a/library/Director/Web/Widget/DeployedConfigInfoHeader.php b/library/Director/Web/Widget/DeployedConfigInfoHeader.php new file mode 100644 index 0000000..0e841f3 --- /dev/null +++ b/library/Director/Web/Widget/DeployedConfigInfoHeader.php @@ -0,0 +1,101 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use Icinga\Module\Director\Db\Branch\Branch; +use ipl\Html\HtmlDocument; +use Icinga\Module\Director\Core\DeploymentApiInterface; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Forms\DeployConfigForm; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\NameValueTable; + +class DeployedConfigInfoHeader extends HtmlDocument +{ + use TranslationHelper; + + /** @var IcingaConfig */ + protected $config; + + /** @var int */ + protected $deploymentId; + + /** @var Db */ + protected $db; + + /** @var DeploymentApiInterface */ + protected $api; + + /** @var Branch */ + protected $branch; + + public function __construct( + IcingaConfig $config, + Db $db, + DeploymentApiInterface $api, + Branch $branch, + $deploymentId = null + ) { + $this->config = $config; + $this->db = $db; + $this->api = $api; + $this->branch = $branch; + if ($deploymentId) { + $this->deploymentId = (int) $deploymentId; + } + } + + /** + * @throws \Icinga\Exception\IcingaException + * @throws \Zend_Form_Exception + */ + protected function assemble() + { + $config = $this->config; + if ($this->branch->isBranch()) { + $deployForm = null; + } else { + $deployForm = DeployConfigForm::load() + ->setDb($this->db) + ->setApi($this->api) + ->setChecksum($config->getHexChecksum()) + ->setDeploymentId($this->deploymentId) + ->setAttrib('class', 'inline') + ->handleRequest(); + } + + $links = new NameValueTable(); + $links->addNameValueRow( + $this->translate('Actions'), + [ + $deployForm, + Html::tag('br'), + Link::create( + $this->translate('Last related activity'), + 'director/config/activity', + ['checksum' => $config->getLastActivityHexChecksum()], + ['class' => 'icon-clock', 'data-base-target' => '_next'] + ), + Html::tag('br'), + Link::create( + $this->translate('Diff with other config'), + 'director/config/diff', + ['left' => $config->getHexChecksum()], + ['class' => 'icon-flapping', 'data-base-target' => '_self'] + ) + ] + )->addNameValueRow( + $this->translate('Statistics'), + sprintf( + $this->translate('%d files rendered in %0.2fs'), + count($config->getFiles()), + $config->getDuration() / 1000 + ) + ); + + $this->add($links); + } +} diff --git a/library/Director/Web/Widget/DeploymentInfo.php b/library/Director/Web/Widget/DeploymentInfo.php new file mode 100644 index 0000000..110200f --- /dev/null +++ b/library/Director/Web/Widget/DeploymentInfo.php @@ -0,0 +1,169 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\HtmlDocument; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use Icinga\Module\Director\Objects\DirectorDeploymentLog; +use Icinga\Module\Director\StartupLogRenderer; +use Icinga\Util\Format; +use Icinga\Web\Request; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Icon; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\NameValueTable; +use gipfl\IcingaWeb2\Widget\Tabs; + +class DeploymentInfo extends HtmlDocument +{ + use TranslationHelper; + + /** @var DirectorDeploymentLog */ + protected $deployment; + + /** @var IcingaConfig */ + protected $config; + + /** + * DeploymentInfo constructor. + * @param DirectorDeploymentLog $deployment + */ + public function __construct(DirectorDeploymentLog $deployment) + { + $this->deployment = $deployment; + if ($deployment->get('config_checksum') !== null) { + $this->config = IcingaConfig::load( + $deployment->get('config_checksum'), + $deployment->getConnection() + ); + } + } + + /** + * @param Auth $auth + * @param Request $request + * @return Tabs + */ + public function getTabs(Auth $auth, Request $request) + { + $dep = $this->deployment; + $tabs = new Tabs(); + $tabs->add('deployment', array( + 'label' => $this->translate('Deployment'), + 'url' => $request->getUrl() + ))->activate('deployment'); + + if ($dep->config_checksum !== null && $auth->hasPermission('director/showconfig')) { + $tabs->add('config', array( + 'label' => $this->translate('Config'), + 'url' => 'director/config/files', + 'urlParams' => array( + 'checksum' => $this->config->getHexChecksum(), + 'deployment_id' => $dep->id + ) + )); + } + + return $tabs; + } + + protected function createInfoTable() + { + $dep = $this->deployment; + $table = new NameValueTable(); + $table->addNameValuePairs([ + $this->translate('Deployment time') => $dep->start_time, + $this->translate('Sent to') => $dep->peer_identity, + ]); + if ($this->config !== null) { + $table->addNameValuePairs([ + $this->translate('Configuration') => $this->getConfigDetails(), + $this->translate('Duration') => $this->getDurationInfo(), + ]); + } + $table->addNameValuePairs([ + $this->translate('Stage name') => $dep->stage_name, + $this->translate('Startup') => $this->getStartupInfo() + ]); + + return $table; + } + + protected function getDurationInfo() + { + return sprintf( + $this->translate('Rendered in %0.2fs, deployed in %0.2fs'), + $this->config->getDuration() / 1000, + $this->deployment->duration_dump / 1000 + ); + } + + protected function getConfigDetails() + { + $cfg = $this->config; + $dep = $this->deployment; + + return [ + Link::create( + sprintf($this->translate('%d files'), $cfg->getFileCount()), + 'director/config/files', + [ + 'checksum' => $cfg->getHexChecksum(), + 'deployment_id' => $dep->id + ] + ), + ', ', + sprintf( + $this->translate('%d objects, %d templates, %d apply rules'), + $cfg->getObjectCount(), + $cfg->getTemplateCount(), + $cfg->getApplyCount() + ), + ', ', + Format::bytes($cfg->getSize()) + ]; + } + + protected function getStartupInfo() + { + $dep = $this->deployment; + if ($dep->startup_succeeded === null) { + if ($dep->stage_collected === null) { + return [$this->translate('Unknown, still waiting for config check outcome'), new Icon('spinner')]; + } else { + return [$this->translate('Unknown, failed to collect related information'), new Icon('help')]; + } + } elseif ($dep->startup_succeeded === 'y') { + return $this->colored('green', [$this->translate('Succeeded'), new Icon('ok')]); + } else { + return $this->colored('red', [$this->translate('Failed'), new Icon('cancel')]); + } + } + + protected function colored($color, array $content) + { + return Html::tag('div', ['style' => "color: $color;"], $content)->setSeparator(' '); + } + + public function render() + { + $this->add($this->createInfoTable()); + if ($this->deployment->get('startup_succeeded') !== null) { + $this->addStartupLog(); + } + + return parent::render(); + } + + protected function addStartupLog() + { + $this->add(Html::tag('h2', null, $this->translate('Startup Log'))); + $this->add( + Html::tag('pre', [ + 'class' => 'logfile' + ], new StartupLogRenderer($this->deployment)) + ); + } +} diff --git a/library/Director/Web/Widget/Documentation.php b/library/Director/Web/Widget/Documentation.php new file mode 100644 index 0000000..8665e30 --- /dev/null +++ b/library/Director/Web/Widget/Documentation.php @@ -0,0 +1,97 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use Icinga\Application\ApplicationBootstrap; +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use ipl\Html\Html; + +class Documentation +{ + use TranslationHelper; + + /** @var ApplicationBootstrap */ + protected $app; + + /** @var Auth */ + protected $auth; + + public function __construct(ApplicationBootstrap $app, Auth $auth) + { + $this->app = $app; + $this->auth = $auth; + } + + public static function link($label, $module, $chapter, $title = null) + { + $doc = new static(Icinga::app(), Auth::getInstance()); + return $doc->getModuleLink($label, $module, $chapter, $title); + } + + public function getModuleLink($label, $module, $chapter, $title = null) + { + if ($title !== null) { + $title = sprintf( + $this->translate('Click to read our documentation: %s'), + $title + ); + } + $linkToGitHub = false; + $baseParams = [ + 'class' => 'icon-book', + 'title' => $title, + ]; + if ($this->hasAccessToDocumentationModule()) { + return Link::create( + $label, + $this->getDirectorDocumentationUrl($chapter), + null, + ['data-base-target' => '_next'] + $baseParams + ); + } + + $baseParams['target'] = '_blank'; + if ($linkToGitHub) { + return Html::tag('a', [ + 'href' => $this->githubDocumentationUrl($module, $chapter), + ] + $baseParams, $label); + } + + return Html::tag('a', [ + 'href' => $this->icingaDocumentationUrl($module, $chapter), + ] + $baseParams, $label); + } + + protected function getDirectorDocumentationUrl($chapter) + { + return 'doc/module/director/chapter/' + . \preg_replace('/^\d+-/', '', \rawurlencode($chapter)); + } + + protected function githubDocumentationUrl($module, $chapter) + { + return sprintf( + "https://github.com/Icinga/icingaweb2-module-%s/blob/master/doc/%s.md", + \rawurlencode($module), + \rawurlencode($chapter) + ); + } + + protected function icingaDocumentationUrl($module, $chapter) + { + return sprintf( + 'https://icinga.com/docs/%s/latest/doc/%s/', + \rawurlencode($module), + \rawurlencode($chapter) + ); + } + + protected function hasAccessToDocumentationModule() + { + return $this->app->getModuleManager()->hasLoaded('doc') + && $this->auth->hasPermission('module/doc'); + } +} diff --git a/library/Director/Web/Widget/HealthCheckPluginOutput.php b/library/Director/Web/Widget/HealthCheckPluginOutput.php new file mode 100644 index 0000000..83ac102 --- /dev/null +++ b/library/Director/Web/Widget/HealthCheckPluginOutput.php @@ -0,0 +1,94 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use gipfl\Translation\TranslationHelper; +use Icinga\Module\Director\CheckPlugin\PluginState; +use Icinga\Module\Director\Health; + +class HealthCheckPluginOutput extends HtmlDocument +{ + use TranslationHelper; + + /** @var Health */ + protected $health; + + /** @var PluginState */ + protected $state; + + public function __construct(Health $health) + { + $this->state = new PluginState('OK'); + $this->health = $health; + $this->process(); + } + + protected function process() + { + $checks = $this->health->getAllChecks(); + + foreach ($checks as $check) { + $this->add([ + $title = Html::tag('h1', $check->getName()), + $ul = Html::tag('ul', ['class' => 'health-check-result']) + ]); + + $problems = $check->getProblemSummary(); + if (! empty($problems)) { + $badges = Html::tag('span', ['class' => 'title-badges']); + foreach ($problems as $state => $count) { + $badges->add(Html::tag('span', [ + 'class' => ['badge', 'state-' . strtolower($state)], + 'title' => sprintf( + $this->translate('%s: %d'), + $this->translate($state), + $count + ), + ], $count)); + } + $title->add($badges); + } + + foreach ($check->getResults() as $result) { + $state = $result->getState()->getName(); + $ul->add(Html::tag('li', [ + 'class' => 'state state-' . strtolower($state) + ], $this->highlightNames($result->getOutput()))->setSeparator(' ')); + } + $this->state->raise($check->getState()); + } + } + + public function getState() + { + return $this->state; + } + + protected function colorizeState($state) + { + return Html::tag('span', ['class' => 'badge state-' . strtolower($state)], $state); + } + + protected function highlightNames($string) + { + $string = Html::escape($string); + return new HtmlString(preg_replace_callback( + "/'([^']+)'/", + [$this, 'highlightName'], + $string + )); + } + + protected function highlightName($match) + { + return '"' . Html::tag('strong', $match[1]) . '"'; + } + + protected function getColorized($match) + { + return $this->colorizeState($match[1]); + } +} diff --git a/library/Director/Web/Widget/IcingaConfigDiff.php b/library/Director/Web/Widget/IcingaConfigDiff.php new file mode 100644 index 0000000..800f1d9 --- /dev/null +++ b/library/Director/Web/Widget/IcingaConfigDiff.php @@ -0,0 +1,58 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Diff\HtmlRenderer\SideBySideDiff; +use gipfl\Diff\PhpDiff; +use Icinga\Module\Director\IcingaConfig\IcingaConfig; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\ValidHtml; + +class IcingaConfigDiff extends HtmlDocument +{ + public function __construct(IcingaConfig $left, IcingaConfig $right) + { + foreach (static::getDiffs($left, $right) as $filename => $diff) { + $this->add([ + Html::tag('h3', $filename), + $diff + ]); + } + } + + /** + * @param IcingaConfig $oldConfig + * @param IcingaConfig $newConfig + * @return ValidHtml[] + */ + public static function getDiffs(IcingaConfig $oldConfig, IcingaConfig $newConfig) + { + $oldFileNames = $oldConfig->getFileNames(); + $newFileNames = $newConfig->getFileNames(); + + $fileNames = array_merge($oldFileNames, $newFileNames); + + $diffs = []; + foreach ($fileNames as $filename) { + if (in_array($filename, $oldFileNames)) { + $left = $oldConfig->getFile($filename)->getContent(); + } else { + $left = ''; + } + + if (in_array($filename, $newFileNames)) { + $right = $newConfig->getFile($filename)->getContent(); + } else { + $right = ''; + } + if ($left === $right) { + continue; + } + + $diffs[$filename] = new SideBySideDiff(new PhpDiff($left, $right)); + } + + return $diffs; + } +} diff --git a/library/Director/Web/Widget/IcingaObjectInspection.php b/library/Director/Web/Widget/IcingaObjectInspection.php new file mode 100644 index 0000000..61f3567 --- /dev/null +++ b/library/Director/Web/Widget/IcingaObjectInspection.php @@ -0,0 +1,254 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\NameValueTable; +use Icinga\Date\DateFormatter; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\PlainObjectRenderer; +use Icinga\Module\Director\Web\Table\DbHelper; +use stdClass; + +class IcingaObjectInspection extends BaseHtmlElement +{ + use DbHelper; + use TranslationHelper; + + protected $tag = 'div'; + + /** @var Db */ + protected $db; + + /** @var stdClass */ + protected $object; + + public function __construct(stdClass $object, Db $db) + { + $this->object = $object; + $this->db = $db; + } + + /** + * @throws \Icinga\Exception\IcingaException + */ + protected function assemble() + { + $attrs = $this->object->attrs; + if (isset($attrs->source_location)) { + $this->renderSourceLocation($attrs->source_location); + } + if (isset($attrs->last_check_result)) { + $this->renderLastCheckResult($attrs->last_check_result); + } + + $this->renderObjectAttributes($attrs); + // $this->add(Html::tag('pre', null, PlainObjectRenderer::render($this->object))); + } + + /** + * @param $result + * @throws \Icinga\Exception\IcingaException + */ + protected function renderLastCheckResult($result) + { + $this->add(Html::tag('h2', null, $this->translate('Last Check Result'))); + $this->renderCheckResultDetails($result); + if (property_exists($result, 'command')) { + $this->renderExecutedCommand($result->command); + } + } + + /** + * @param array|string $command + * + * @throws \Icinga\Exception\IcingaException + */ + protected function renderExecutedCommand($command) + { + if (is_array($command)) { + $command = implode(' ', array_map('escapeshellarg', $command)); + } + $this->add([ + Html::tag('h3', null, 'Executed Command'), + $this->formatConsole($command) + ]); + } + + protected function renderCheckResultDetails($result) + { + } + + /** + * @param $attrs + * @throws \Icinga\Exception\IcingaException + */ + protected function renderObjectAttributes($attrs) + { + $blacklist = [ + 'last_check_result', + 'source_location', + 'templates', + ]; + + $linked = [ + 'check_command', + 'groups', + ]; + + $info = new NameValueTable(); + foreach ($attrs as $key => $value) { + if (in_array($key, $blacklist)) { + continue; + } + if ($key === 'groups') { + $info->addNameValueRow($key, $this->linkGroups($value)); + } elseif (in_array($key, $linked)) { + $info->addNameValueRow($key, $this->renderLinkedObject($key, $value)); + } else { + $info->addNameValueRow($key, PlainObjectRenderer::render($value)); + } + } + + $this->add([ + Html::tag('h2', null, 'Object Properties'), + $info + ]); + } + + /** + * @param $key + * @param $objectName + * @return Link|Link[] + * @throws \Icinga\Exception\IcingaException + * @throws \Icinga\Exception\ProgrammingError + */ + protected function renderLinkedObject($key, $objectName) + { + $keys = [ + 'check_command' => ['CheckCommand', 'CheckCommands'], + 'event_command' => ['EventCommand', 'EventCommands'], + 'notification_command' => ['NotificationCommand', 'NotificationCommands'], + ]; + $type = $keys[$key]; + + if ($key === 'groups') { + return $this->linkGroups($objectName); + } else { + $singular = $type[0]; + $plural = $type[1]; + + return Link::create($objectName, 'director/inspect/object', [ + 'type' => $singular, + 'plural' => $plural, + 'name' => $objectName + ]); + } + } + + /** + * @param $groups + * @return Link[] + * @throws \Icinga\Exception\IcingaException + * @throws \Icinga\Exception\ProgrammingError + */ + protected function linkGroups($groups) + { + if ($groups === null) { + return []; + } + + $singular = $this->object->type . 'Group'; + $plural = $singular . "s"; + + $links = []; + + foreach ($groups as $name) { + $links[] = Link::create($name, 'director/inspect/object', [ + 'type' => $singular, + 'plural' => $plural, + 'name' => $name + ]); + } + + return $links; + } + + /** + * @param stdClass $source + * @throws \Icinga\Exception\IcingaException + */ + protected function renderSourceLocation(stdClass $source) + { + $findRelative = 'api/packages/director'; + $this->add(Html::tag('h2')->add('Source Location')); + $pos = strpos($source->path, $findRelative); + + if (false === $pos) { + $this->add(Html::tag('p', null, Html::sprintf( + 'The configuration for this object has not been rendered by' + . ' Icinga Director. You can find it on line %s in %s.', + Html::tag('strong', null, $source->first_line), + Html::tag('strong', null, $source->path) + ))); + } else { + $relativePath = substr($source->path, $pos + strlen($findRelative) + 1); + $parts = explode('/', $relativePath); + $stageName = array_shift($parts); + $relativePath = implode('/', $parts); + $source->director_relative = $relativePath; + $deployment = $this->loadDeploymentForStage($stageName); + + $this->add(Html::tag('p')->add(Html::sprintf( + 'The configuration for this object has been rendered by Icinga' + . ' Director %s to %s', + DateFormatter::timeAgo(strtotime($deployment->start_time, false)), + $this->linkToSourceLocation($deployment, $source) + ))); + } + } + + protected function loadDeploymentForStage($stageName) + { + $db = $this->db->getDbAdapter(); + $query = $db->select()->from( + ['dl' => 'director_deployment_log'], + ['id', 'start_time', 'config_checksum'] + )->where('stage_name = ?', $stageName)->order('id DESC')->limit(1); + + return $db->fetchRow($query); + } + + /** + * @param $deployment + * @param $source + * @return Link + * @throws \Icinga\Exception\IcingaException + * @throws \Icinga\Exception\ProgrammingError + */ + protected function linkToSourceLocation($deployment, $source) + { + $filename = $source->director_relative; + + return Link::create( + sprintf('%s:%s', $filename, $source->first_line), + 'director/config/file', + [ + 'config_checksum' => $this->getChecksum($deployment->config_checksum), + 'deployment_id' => $deployment->id, + 'backTo' => 'deployment', + 'file_path' => $filename, + 'highlight' => $source->first_line, + 'highlightSeverity' => 'ok' + ] + ); + } + + protected function formatConsole($output) + { + return Html::tag('pre', ['class' => 'logfile'], $output); + } +} diff --git a/library/Director/Web/Widget/ImportSourceDetails.php b/library/Director/Web/Widget/ImportSourceDetails.php new file mode 100644 index 0000000..32eef7f --- /dev/null +++ b/library/Director/Web/Widget/ImportSourceDetails.php @@ -0,0 +1,83 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Web\Widget\Hint; +use ipl\Html\HtmlDocument; +use Icinga\Module\Director\Forms\ImportCheckForm; +use Icinga\Module\Director\Forms\ImportRunForm; +use Icinga\Module\Director\Objects\ImportSource; +use ipl\Html\Html; +use gipfl\Translation\TranslationHelper; + +class ImportSourceDetails extends HtmlDocument +{ + use TranslationHelper; + + protected $source; + + public function __construct(ImportSource $source) + { + $this->source = $source; + } + + protected function assemble() + { + $source = $this->source; + $description = $source->get('description'); + if ($description !== null && strlen($description)) { + $this->add(Html::tag('p', null, $description)); + } + + switch ($source->get('import_state')) { + case 'unknown': + $this->add(Hint::warning($this->translate( + "It's currently unknown whether we are in sync with this Import Source." + . ' You should either check for changes or trigger a new Import Run.' + ))); + break; + case 'in-sync': + $this->add(Hint::ok(sprintf( + $this->translate( + 'This Import Source was last found to be in sync at %s.' + ), + $source->last_attempt + ))); + // TODO: check whether... + // - there have been imports since then, differing from former ones + // - there have been activities since then + break; + case 'pending-changes': + $this->add(Hint::warning($this->translate( + 'There are pending changes for this Import Source. You should trigger a new' + . ' Import Run.' + ))); + break; + case 'failing': + $this->add(Hint::error(sprintf( + $this->translate( + 'This Import Source failed when last checked at %s: %s' + ), + $source->last_attempt, + $source->last_error_message + ))); + break; + default: + $this->add(Hint::error(sprintf( + $this->translate('This Import Source has an invalid state: %s'), + $source->get('import_state') + ))); + } + + $this->add( + ImportCheckForm::load() + ->setImportSource($source) + ->handleRequest() + ); + $this->add( + ImportRunForm::load() + ->setImportSource($source) + ->handleRequest() + ); + } +} diff --git a/library/Director/Web/Widget/InspectPackages.php b/library/Director/Web/Widget/InspectPackages.php new file mode 100644 index 0000000..f9b8864 --- /dev/null +++ b/library/Director/Web/Widget/InspectPackages.php @@ -0,0 +1,174 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\IcingaEndpoint; +use ipl\Html\Html; +use ipl\Html\Table; + +class InspectPackages +{ + use TranslationHelper; + + /** @var Db */ + protected $db; + + /** @var string */ + protected $baseUrl; + + public function __construct(Db $db, $baseUrl) + { + $this->db = $db; + $this->baseUrl = $baseUrl; + } + + public function getContent(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null) + { + if ($endpoint === null) { + return $this->getRootEndpoints(); + } elseif ($package === null) { + return $this->getPackages($endpoint); + } elseif ($stage === null) { + return $this->getStages($endpoint, $package); + } elseif ($file === null) { + return $this->getFiles($endpoint, $package, $stage); + } else { + return $this->getFile($endpoint, $package, $stage, $file); + } + } + + public function getTitle(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null) + { + if ($endpoint === null) { + return $this->translate('Endpoint in your Root Zone'); + } elseif ($package === null) { + return \sprintf($this->translate('Packages on Endpoint: %s'), $endpoint->getObjectName()); + } elseif ($stage === null) { + return \sprintf($this->translate('Stages in Package: %s'), $package); + } elseif ($file === null) { + return \sprintf($this->translate('Files in Stage: %s'), $stage); + } else { + return \sprintf($this->translate('File Content: %s'), $file); + } + } + + public function getBreadCrumb(IcingaEndpoint $endpoint = null, $package = null, $stage = null) + { + $parts = [ + 'endpoint' => $endpoint === null ? null : $endpoint->getObjectName(), + 'package' => $package, + 'stage' => $stage, + ]; + + $params = []; + // No root zone link for now: + // $result = [Link::create($this->translate('Root Zone'), $this->baseUrl)]; + $result = [Html::tag('a', ['href' => '#'], $this->translate('Root Zone'))]; + foreach ($parts as $name => $value) { + if ($value === null) { + break; + } + $params[$name] = $value; + $result[] = Link::create($value, $this->baseUrl, $params); + } + + return Html::tag('ul', ['class' => 'breadcrumb'], Html::wrapEach($result, 'li')); + } + + protected function getRootEndpoints() + { + $table = $this->prepareTable(); + foreach ($this->db->getEndpointNamesInDeploymentZone() as $name) { + $table->add(Table::row([ + Link::create($name, $this->baseUrl, [ + 'endpoint' => $name, + ]) + ])); + } + + return $table; + } + + protected function getPackages(IcingaEndpoint $endpoint) + { + $table = $this->prepareTable(); + $api = $endpoint->api(); + foreach ($api->getPackages() as $package) { + $table->add(Table::row([ + Link::create($package->name, $this->baseUrl, [ + 'endpoint' => $endpoint->getObjectName(), + 'package' => $package->name, + ]) + ])); + } + + return $table; + } + + protected function getStages(IcingaEndpoint $endpoint, $packageName) + { + $table = $this->prepareTable(); + $api = $endpoint->api(); + foreach ($api->getPackages() as $package) { + if ($package->name !== $packageName) { + continue; + } + foreach ($package->stages as $stage) { + $label = [$stage]; + if ($stage === $package->{'active-stage'}) { + $label[] = Html::tag('small', [' (', $this->translate('active'), ')']); + } + + $table->add(Table::row([ + Link::create($label, $this->baseUrl, [ + 'endpoint' => $endpoint->getObjectName(), + 'package' => $package->name, + 'stage' => $stage + ]) + ])); + } + } + + return $table; + } + + protected function getFiles(IcingaEndpoint $endpoint, $package, $stage) + { + $table = $this->prepareTable(); + $table->getAttributes()->set('data-base-target', '_next'); + foreach ($endpoint->api()->listStageFiles($stage, $package) as $filename) { + $table->add($table->row([ + Link::create($filename, $this->baseUrl, [ + 'endpoint' => $endpoint->getObjectName(), + 'package' => $package, + 'stage' => $stage, + 'file' => $filename + ]) + ])); + } + + return $table; + } + + protected function getFile(IcingaEndpoint $endpoint, $package, $stage, $file) + { + return Html::tag('pre', $endpoint->api()->getStagedFile($stage, $file, $package)); + } + + protected function prepareTable($headerCols = []) + { + $table = new Table(); + $table->addAttributes([ + 'class' => ['common-table', 'table-row-selectable'], + 'data-base-target' => '_self' + ]); + if (! empty($headerCols)) { + $table->add($table::row($headerCols, null, 'th')); + } + + return $table; + } +} diff --git a/library/Director/Web/Widget/JobDetails.php b/library/Director/Web/Widget/JobDetails.php new file mode 100644 index 0000000..3a530a2 --- /dev/null +++ b/library/Director/Web/Widget/JobDetails.php @@ -0,0 +1,69 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Web\Widget\Hint; +use Icinga\Date\DateFormatter; +use ipl\Html\HtmlDocument; +use Icinga\Module\Director\Objects\DirectorJob; +use ipl\Html\Html; +use gipfl\Translation\TranslationHelper; + +class JobDetails extends HtmlDocument +{ + use TranslationHelper; + + /** + * JobDetails constructor. + * @param DirectorJob $job + * @throws \Icinga\Exception\NotFoundError + */ + public function __construct(DirectorJob $job) + { + $runInterval = $job->get('run_interval'); + if ($job->hasBeenDisabled()) { + $this->add(Hint::error(sprintf( + $this->translate( + 'This job would run every %ds. It has been disabled and will' + . ' therefore not be executed as scheduled' + ), + $runInterval + ))); + } else { + //$class = $job->job(); echo $class::getDescription() + $msg = $job->isPending() + ? sprintf( + $this->translate('This job runs every %ds and is currently pending'), + $runInterval + ) + : sprintf( + $this->translate('This job runs every %ds.'), + $runInterval + ); + $this->add(Html::tag('p', null, $msg)); + } + + $tsLastAttempt = $job->get('ts_last_attempt'); + if ($tsLastAttempt) { + $ts = \strtotime($tsLastAttempt); + $timeAgo = Html::tag('span', [ + 'class' => 'time-ago', + 'title' => DateFormatter::formatDateTime($ts) + ], DateFormatter::timeAgo($ts)); + if ($job->get('last_attempt_succeeded') === 'y') { + $this->add(Hint::ok(Html::sprintf( + $this->translate('The last attempt succeeded %s'), + $timeAgo + ))); + } else { + $this->add(Hint::error(Html::sprintf( + $this->translate('The last attempt failed %s: %s'), + $timeAgo, + $job->get('last_error_message') + ))); + } + } else { + $this->add(Hint::warning($this->translate('This job has not been executed yet'))); + } + } +} diff --git a/library/Director/Web/Widget/ListItem.php b/library/Director/Web/Widget/ListItem.php new file mode 100644 index 0000000..ec326cc --- /dev/null +++ b/library/Director/Web/Widget/ListItem.php @@ -0,0 +1,26 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Html\ValidHtml; + +class ListItem extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + /** + * @param ValidHtml|array|string $content + * @param Attributes|array $attributes + * + * @return $this + */ + public function addItem($content, $attributes = null) + { + return $this->add( + Html::tag('li', $attributes, $content) + ); + } +} diff --git a/library/Director/Web/Widget/NotInBranchedHint.php b/library/Director/Web/Widget/NotInBranchedHint.php new file mode 100644 index 0000000..222934b --- /dev/null +++ b/library/Director/Web/Widget/NotInBranchedHint.php @@ -0,0 +1,23 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\Widget\Hint; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Db\Branch\Branch; +use ipl\Html\Html; + +class NotInBranchedHint extends Hint +{ + use TranslationHelper; + + public function __construct($forbiddenAction, Branch $branch, Auth $auth) + { + parent::__construct(Html::sprintf( + $this->translate('%s is not available while being in a Configuration Branch: %s'), + $forbiddenAction, + Branch::requireHook()->linkToBranch($branch, $auth, $branch->getName()) + ), 'info'); + } +} diff --git a/library/Director/Web/Widget/OrderedList.php b/library/Director/Web/Widget/OrderedList.php new file mode 100644 index 0000000..8f888de --- /dev/null +++ b/library/Director/Web/Widget/OrderedList.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +class OrderedList extends AbstractList +{ + protected $tag = 'ol'; +} diff --git a/library/Director/Web/Widget/ShowConfigFile.php b/library/Director/Web/Widget/ShowConfigFile.php new file mode 100644 index 0000000..77d32cf --- /dev/null +++ b/library/Director/Web/Widget/ShowConfigFile.php @@ -0,0 +1,106 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use ipl\Html\HtmlDocument; +use Icinga\Module\Director\IcingaConfig\IcingaConfigFile; +use ipl\Html\Html; +use ipl\Html\HtmlString; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; + +class ShowConfigFile extends HtmlDocument +{ + use TranslationHelper; + + protected $file; + + protected $highlight; + + protected $highlightSeverity; + + public function __construct( + IcingaConfigFile $file, + $highlight = null, + $highlightSeverity = null + ) { + $this->file = $file; + $this->highlight = $highlight; + $this->highlightSeverity = $highlightSeverity; + } + + /** + * @throws \Icinga\Exception\IcingaException + */ + protected function assemble() + { + $source = $this->linkObjects(Html::escape($this->file->getContent())); + if ($this->highlight) { + $source = $this->highlight( + $source, + $this->highlight, + $this->highlightSeverity + ); + } + + $this->add(Html::tag( + 'pre', + ['class' => 'generated-config'], + new HtmlString($source) + )); + } + + /** + * @param $match + * @return string + * @throws \Icinga\Exception\IcingaException + * @throws \Icinga\Exception\ProgrammingError + */ + protected function linkObject($match) + { + if ($match[2] === 'Service') { + return $match[0]; + } + $controller = $match[2]; + + if ($match[2] === 'CheckCommand') { + $controller = 'command'; + } + + $name = $this->decode($match[3]); + return sprintf( + '%s %s "%s" {', + $match[1], + $match[2], + Link::create( + $name, + 'director/' . $controller, + ['name' => $name], + ['data-base-target' => '_next'] + ) + ); + } + + protected function decode($str) + { + return htmlspecialchars_decode($str, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5); + } + + protected function linkObjects($config) + { + $pattern = '/^(object|template)\s([A-Z][A-Za-z]*?)\s"(.+?)"\s{/m'; + + return preg_replace_callback( + $pattern, + [$this, 'linkObject'], + $config + ); + } + + protected function highlight($what, $line, $severity) + { + $lines = explode("\n", $what); + $lines[$line - 1] = '<span class="highlight ' . $severity . '">' . $lines[$line - 1] . '</span>'; + return implode("\n", $lines); + } +} diff --git a/library/Director/Web/Widget/SyncRunDetails.php b/library/Director/Web/Widget/SyncRunDetails.php new file mode 100644 index 0000000..408e8f6 --- /dev/null +++ b/library/Director/Web/Widget/SyncRunDetails.php @@ -0,0 +1,129 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +use Icinga\Module\Director\Objects\DirectorActivityLog; +use ipl\Html\HtmlDocument; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\SyncRun; +use gipfl\IcingaWeb2\Link; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Widget\NameValueTable; +use function sprintf; + +class SyncRunDetails extends NameValueTable +{ + use TranslationHelper; + + const URL_ACTIVITIES = 'director/config/activities'; + + /** @var SyncRun */ + protected $run; + + public function __construct(SyncRun $run) + { + $this->run = $run; + $this->getAttributes()->add('data-base-target', '_next'); // eigentlich nur runSummary + $this->addNameValuePairs([ + $this->translate('Start time') => $run->get('start_time'), + $this->translate('Duration') => sprintf('%.2fs', $run->get('duration_ms') / 1000), + $this->translate('Activity') => $this->runSummary($run) + ]); + } + + /** + * @param SyncRun $run + * @return array + */ + protected function runSummary(SyncRun $run) + { + $html = []; + $total = $run->countActivities(); + if ($total === 0) { + $html[] = $this->translate('No changes have been made'); + } else { + if ($total === 1) { + $html[] = $this->translate('One object has been modified'); + } else { + $html[] = sprintf( + $this->translate('%s objects have been modified'), + $total + ); + } + + /** @var Db $db */ + $db = $run->getConnection(); + $formerId = $db->fetchActivityLogIdByChecksum($run->get('last_former_activity')); + if ($formerId === null) { + return $html; + } + $lastId = $db->fetchActivityLogIdByChecksum($run->get('last_related_activity')); + + if ($formerId !== $lastId) { + $idRangeEx = sprintf( + 'id>%d&id<=%d', + $formerId, + $lastId + ); + } else { + $idRangeEx = null; + } + + $links = new HtmlDocument(); + $links->setSeparator(', '); + $links->add([ + $this->activitiesLink( + 'objects_created', + $this->translate('%d created'), + DirectorActivityLog::ACTION_CREATE, + $idRangeEx + ), + $this->activitiesLink( + 'objects_modified', + $this->translate('%d modified'), + DirectorActivityLog::ACTION_MODIFY, + $idRangeEx + ), + $this->activitiesLink( + 'objects_deleted', + $this->translate('%d deleted'), + DirectorActivityLog::ACTION_DELETE, + $idRangeEx + ), + ]); + + if ($idRangeEx && count($links) > 1) { + $links->add(new Link( + $this->translate('Show all actions'), + self::URL_ACTIVITIES, + ['idRangeEx' => $idRangeEx] + )); + } + + if (! $links->isEmpty()) { + $html[] = ': '; + $html[] = $links; + } + } + + return $html; + } + + protected function activitiesLink($key, $label, $action, $rangeFilter) + { + $count = $this->run->get($key); + if ($count > 0) { + if ($rangeFilter) { + return new Link( + sprintf($label, $count), + self::URL_ACTIVITIES, + ['action' => $action, 'idRangeEx' => $rangeFilter] + ); + } + + return sprintf($label, $count); + } + + return null; + } +} diff --git a/library/Director/Web/Widget/UnorderedList.php b/library/Director/Web/Widget/UnorderedList.php new file mode 100644 index 0000000..f01dbe3 --- /dev/null +++ b/library/Director/Web/Widget/UnorderedList.php @@ -0,0 +1,8 @@ +<?php + +namespace Icinga\Module\Director\Web\Widget; + +class UnorderedList extends AbstractList +{ + protected $tag = 'ul'; +} diff --git a/library/Director/Web/Window.php b/library/Director/Web/Window.php new file mode 100644 index 0000000..3415dd3 --- /dev/null +++ b/library/Director/Web/Window.php @@ -0,0 +1,13 @@ +<?php + +namespace Icinga\Module\Director\Web; + +use Icinga\Web\Window as WebWindow; + +class Window extends WebWindow +{ + public function __construct($id) + { + parent::__construct(\preg_replace('/_.+$/', '', $id)); + } +} |