summaryrefslogtreecommitdiffstats
path: root/library/Director/Db/Branch
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
commitcd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch)
treefbff2135e7013f196b891bbde54618eb050e4aaf /library/Director/Db/Branch
parentInitial commit. (diff)
downloadicingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.tar.xz
icingaweb2-module-director-cd989f9c3aff968e19a3aeabc4eb9085787a6673.zip
Adding upstream version 1.10.2.upstream/1.10.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Director/Db/Branch')
-rw-r--r--library/Director/Db/Branch/Branch.php216
-rw-r--r--library/Director/Db/Branch/BranchActivity.php390
-rw-r--r--library/Director/Db/Branch/BranchMerger.php157
-rw-r--r--library/Director/Db/Branch/BranchModificationInspection.php93
-rw-r--r--library/Director/Db/Branch/BranchSettings.php121
-rw-r--r--library/Director/Db/Branch/BranchStore.php240
-rw-r--r--library/Director/Db/Branch/BranchSupport.php91
-rw-r--r--library/Director/Db/Branch/BranchedObject.php404
-rw-r--r--library/Director/Db/Branch/MergeError.php37
-rw-r--r--library/Director/Db/Branch/MergeErrorDeleteMissingObject.php15
-rw-r--r--library/Director/Db/Branch/MergeErrorModificationForMissingObject.php15
-rw-r--r--library/Director/Db/Branch/MergeErrorRecreateOnMerge.php15
-rw-r--r--library/Director/Db/Branch/PlainObjectPropertyDiff.php50
-rw-r--r--library/Director/Db/Branch/UuidLookup.php141
14 files changed, 1985 insertions, 0 deletions
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;
+ }
+}