diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
commit | f66ab8dae2f3d0418759f81a3a64dc9517a62449 (patch) | |
tree | fbff2135e7013f196b891bbde54618eb050e4aaf /library/Director/Db | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.tar.xz icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.zip |
Adding upstream version 1.10.2.upstream/1.10.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Director/Db')
26 files changed, 3351 insertions, 0 deletions
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)); + } +} |