diff options
Diffstat (limited to 'library/Director/Core/CoreApi.php')
-rw-r--r-- | library/Director/Core/CoreApi.php | 940 |
1 files changed, 940 insertions, 0 deletions
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; + } +} |