diff --git a/application/controllers/BranchController.php b/application/controllers/BranchController.php new file mode 100644 index 00000000..1a3ce300 --- /dev/null +++ b/application/controllers/BranchController.php @@ -0,0 +1,143 @@ +assertPermission('director/showconfig'); + $uuid = Uuid::fromString($this->params->getRequired('uuid')); + $this->addSingleTab($this->translate('Activity')); + $this->addTitle($this->translate('Branch Activity')); + $activity = $this->loadBranchActivity($uuid); + $this->content()->add($this->prepareActivityInfo($activity)); + $this->showActivity($activity); + } + + protected function prepareActivityInfo($activity) + { + $modification = ObjectModification::fromSerialization(json_decode($activity->change_set)); + /** @var IcingaObject $class IDE hint, it's a string */ + $class = $modification->getClassName(); + $keyParams = (array) $modification->getKeyParams(); // TODO: verify type + $dummy = $class::create($keyParams); + + $table = new NameValueTable(); + $table->addNameValuePairs([ + $this->translate('Author') => 'branch owner', + $this->translate('Date') => date('Y-m-d H:i:s', $activity->change_time / 1000), + $this->translate('Action') => $modification->getAction() + . ' ' . $dummy->getShortTableName() + . ' ' . $dummy->getObjectName(), + $this->translate('Change UUID') => Uuid::fromBytes($activity->uuid)->toString(), + // $this->translate('Actions') => ['Undo form'], + ]); + return $table; + } + + protected function showActivity($activity) + { + $modification = ObjectModification::fromSerialization(Json::decode($activity->change_set)); + /** @var string|DbObject $class */ + $class = $modification->getClassName(); + + // TODO: Show JSON-Diff for non-IcingaObject's + $keyParams = (array) $modification->getKeyParams(); // TODO: verify type + $dummy = $class::create($keyParams, $this->db()); + if ($modification->isCreation()) { + $left = $this->createEmptyConfig(); + $right = $dummy->setProperties( + (array) $modification->getProperties()->jsonSerialize() + )->toSingleIcingaConfig(); + } elseif ($modification->isDeletion()) { + $left = $class::load($keyParams, $this->db())->toSingleIcingaConfig(); + $right = $this->createEmptyConfig(); + } else { + // TODO: highlight properties that have been changed in the meantime + // TODO: Deal with missing $existing + $existing = $class::load($keyParams, $this->db()); + $left = $existing->toSingleIcingaConfig(); + $right = clone($existing); + IcingaObjectModification::applyModification($modification, $right); + $right = $right->toSingleIcingaConfig(); + } + $changes = $this->getConfigDiffs($left, $right); + foreach ($changes as $filename => $diff) { + $this->content()->add([ + Html::tag('h3', $filename), + $diff + ]); + } + } + + protected function createEmptyConfig() + { + return new IcingaConfig($this->db()); + } + + /** + * @param IcingaConfig $oldConfig + * @param IcingaConfig $newConfig + * @return ValidHtml[] + */ + protected function getConfigDiffs(IcingaConfig $oldConfig, IcingaConfig $newConfig) + { + $oldFileNames = $oldConfig->getFileNames(); + $newFileNames = $newConfig->getFileNames(); + + $fileNames = array_merge($oldFileNames, $newFileNames); + + $diffs = []; + foreach ($fileNames as $filename) { + if (in_array($filename, $oldFileNames)) { + $left = $oldConfig->getFile($filename)->getContent(); + } else { + $left = ''; + } + + if (in_array($filename, $newFileNames)) { + $right = $newConfig->getFile($filename)->getContent(); + } else { + $right = ''; + } + if ($left === $right) { + continue; + } + + $diffs[$filename] = new SideBySideDiff(new PhpDiff($left, $right)); + } + + return $diffs; + } + + protected function loadBranchActivity(UuidInterface $uuid) + { + $db = $this->db()->getDbAdapter(); + return $db->fetchRow( + $db->select()->from('director_branch_activity')->where('uuid = ?', $uuid->getBytes()) + ); + } +} diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index ce17642a..41fba575 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -14,7 +14,9 @@ use Icinga\Module\Director\Forms\SettingsForm; use Icinga\Module\Director\IcingaConfig\IcingaConfig; use Icinga\Module\Director\Objects\DirectorDeploymentLog; use Icinga\Module\Director\Settings; +use Icinga\Module\Director\Web\Controller\BranchHelper; use Icinga\Module\Director\Web\Table\ActivityLogTable; +use Icinga\Module\Director\Web\Table\BranchActivityTable; use Icinga\Module\Director\Web\Table\ConfigFileDiffTable; use Icinga\Module\Director\Web\Table\DeploymentLogTable; use Icinga\Module\Director\Web\Table\GeneratedConfigFileTable; @@ -34,6 +36,8 @@ use gipfl\IcingaWeb2\Url; class ConfigController extends ActionController { + use BranchHelper; + protected $isApified = true; protected function checkDirectorPermissions() @@ -147,6 +151,10 @@ class ConfigController extends ActionController return; } $this->assertPermission('director/audit'); + if ($branchUuid = $this->getBranchUuid()) { + $table = new BranchActivityTable($branchUuid, $this->db()); + $this->content()->add($table); + } $this->setAutorefreshInterval(10); $this->tabs(new InfraTabs($this->Auth()))->activate('activitylog'); diff --git a/library/Director/Data/Db/DbObjectStore.php b/library/Director/Data/Db/DbObjectStore.php new file mode 100644 index 00000000..260d5bb6 --- /dev/null +++ b/library/Director/Data/Db/DbObjectStore.php @@ -0,0 +1,107 @@ +connection = $connection; + } + + public function setBranch(Branch $branch) + { + $this->branch = $branch; + } + + protected function typeSupportsBranches($type) + { + return in_array($type, ['host', 'user', 'zone', 'timeperiod']); + } + + /** + * @param string $shortType + * @param string|array $key + * @return DbObject + * @throws NotFoundError + */ + public function load($shortType, $key) + { + return $this->loadWithBranchModification($shortType, $key)[0]; + } + + /** + * @param string $shortType + * @param string|array $key + * @return array + * @throws NotFoundError + */ + public function loadWithBranchModification($shortType, $key) + { + /** @var string|DbObject $class */ + $class = DbObjectTypeRegistry::classByType($shortType); + if ($this->branch && $this->branch->isBranch() && $this->typeSupportsBranches($shortType) && is_string($key)) { + $branchStore = new BranchModificationStore($this->connection, $shortType); + } else { + $branchStore = null; + } + $modification = null; + try { + $object = $class::load($key, $this->connection); + if ($branchStore && $modification = $branchStore->eventuallyLoadModification( + $object->get('id'), + $this->branch->getUuid() + )) { + $object = IcingaObjectModification::applyModification($modification, $object); + } + } catch (NotFoundError $e) { + if ($this->branch && $this->branch->isBranch() && is_string($key)) { + $branchStore = new BranchModificationStore($this->connection, $shortType); + $modification = $branchStore->loadOptionalModificationByName($key, $this->branch->getUuid()); + if ($modification) { + $object = IcingaObjectModification::applyModification($modification); + if ($id = $object->get('id')) { // Object has probably been renamed + try { + // TODO: can be one step I guess, but my brain is slow today ;-) + $renamedObject = $class::load($id, $this->connection); + $object = IcingaObjectModification::applyModification($modification, $renamedObject); + } catch (NotFoundError $e) { + // Well... it was worth trying + $object->setConnection($this->connection); + $object->setBeingLoadedFromDb(); + } + } else { + $object->setConnection($this->connection); + $object->setBeingLoadedFromDb(); + } + } else { + throw $e; + } + } else { + throw $e; + } + } + + return [$object, $modification]; + } +} diff --git a/library/Director/Db/Branch/Branch.php b/library/Director/Db/Branch/Branch.php new file mode 100644 index 00000000..527f3b2c --- /dev/null +++ b/library/Director/Db/Branch/Branch.php @@ -0,0 +1,119 @@ +get('director/branch'); + + if ($branch !== null) { + $self->branchUuid = Uuid::fromString($branch); + } + + return $self; + } + + /** + * @return Branch + */ + public static function detect() + { + try { + return static::forRequest(Icinga::app()->getRequest()); + } catch (\Exception $e) { + return new static(); + } + } + + /** + * @param Request $request + * @return Branch + */ + public static function forRequest(Request $request) + { + if ($hook = static::optionalHook()) { + return $hook->getBranchForRequest($request); + } + + 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; + } + + /** + * @return bool + */ + public function isMain() + { + return $this->branchUuid === null; + } + + /** + * @return UuidInterface|null + */ + public function getUuid() + { + return $this->branchUuid; + } +} diff --git a/library/Director/Db/Branch/BranchActivityStore.php b/library/Director/Db/Branch/BranchActivityStore.php new file mode 100644 index 00000000..cf0d33fe --- /dev/null +++ b/library/Director/Db/Branch/BranchActivityStore.php @@ -0,0 +1,89 @@ +connection = $connection; + $this->db = $connection->getDbAdapter(); + } + + public function count(UuidInterface $branchUuid) + { + $query = $this->db->select() + ->from($this->table, ['cnt' => 'COUNT(*)']) + ->where('branch_uuid = ?', $branchUuid->getBytes()); + + return (int) $this->db->fetchOne($query); + } + + public function loadAll(UuidInterface $branchUuid) + { + $query = $this->db->select() + ->from($this->table) + ->where('branch_uuid = ?', $branchUuid->getBytes()) + ->order('change_time DESC'); + return $this->db->fetchAll($query); + } + + public static function objectModificationForDbRow($row) + { + $modification = ObjectModification::fromSerialization(json_decode($row->change_set)); + return $modification; + } + + /** + * Must be run in a transaction! + * + * @param ObjectModification $modification + * @param UuidInterface $branchUuid + * @throws \Icinga\Module\Director\Exception\JsonEncodeException + * @throws \Zend_Db_Adapter_Exception + */ + public function persistModification(ObjectModification $modification, UuidInterface $branchUuid) + { + $db = $this->db; + $last = $db->fetchOne( + $db->select() + ->from('director_branch_activity', 'checksum') + ->order('change_time DESC') + ->order('uuid') // Just in case, this gives a guaranteed order + ); + // TODO: eventually implement more checks, allow only one change per millisecond + // alternatively use last change_time plus one, when now < change_time + if (strlen($last) !== 20) { + $last = ''; + } + $binaryUuid = Uuid::uuid4()->getBytes(); + $timestampMs = $this->now(); + $encoded = Json::encode($modification); + + // HINT: checksums are useless! -> merge only + $this->db->insert('director_branch_activity', [ + 'uuid' => $binaryUuid, + 'branch_uuid' => $branchUuid->getBytes(), + 'change_set' => $encoded, // TODO: rename -> object_modification + 'change_time' => $timestampMs, // TODO: ns!! + 'checksum' => sha1("$last/$binaryUuid/$timestampMs/$encoded", true), + 'parent_checksum' => $last === '' ? null : $last, + ]); + } + + protected function now() + { + return floor(microtime(true) * 1000); + } +} diff --git a/library/Director/Db/Branch/BranchMerger.php b/library/Director/Db/Branch/BranchMerger.php new file mode 100644 index 00000000..696144c0 --- /dev/null +++ b/library/Director/Db/Branch/BranchMerger.php @@ -0,0 +1,152 @@ +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 array $uuids + */ + public function ignoreUuids(array $uuids) + { + foreach ($uuids as $uuid) { + $this->ignoreUuid($uuid); + } + } + + /** + * @param UuidInterface|string $uuid + */ + public function ignoreUuid($uuid) + { + if (is_string($uuid)) { + $uuid = Uuid::fromString($uuid); + } elseif (! ($uuid instanceof UuidInterface)) { + throw new InvalidDataException('UUID', $uuid); + } + $binary = $uuid->getBytes(); + $this->ignoreUuids[$binary] = $binary; + } + + /** + * @throws MergeError + * @throws \Exception + */ + public function merge() + { + $this->connection->runFailSafeTransaction(function () { + $activities = new BranchActivityStore($this->connection); + $rows = $activities->loadAll($this->branchUuid); + foreach ($rows as $row) { + $modification = BranchActivityStore::objectModificationForDbRow($row); + $this->applyModification($modification, Uuid::fromBytes($row->uuid)); + } + $this->db->delete('director_branch', $this->db->quoteInto('uuid = ?', $this->branchUuid->getBytes())); + }); + } + + /** + * @param ObjectModification $modification + * @param UuidInterface $uuid + * @throws MergeError + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + protected function applyModification(ObjectModification $modification, UuidInterface $uuid) + { + $binaryUuid = $uuid->getBytes(); + /** @var string|DbObject $class */ + $class = $modification->getClassName(); + $keyParams = (array) $modification->getKeyParams(); + if (array_keys($keyParams) === ['object_name']) { + $keyParams = $keyParams['object_name']; + } + + $exists = $class::exists($keyParams, $this->connection); + if ($modification->isCreation()) { + if ($exists) { + if (! isset($this->ignoreUuids[$uuid->getBytes()])) { + throw new MergeErrorRecreateOnMerge($modification, $uuid); + } + } else { + $object = IcingaObjectModification::applyModification($modification); + $object->store($this->connection); + } + } elseif ($modification->isDeletion()) { + if ($exists) { + $object = IcingaObjectModification::applyModification($modification, $class::load($keyParams, $this->connection)); + $object->setConnection($this->connection); + $object->delete(); + } elseif (! $this->ignoreDeleteWhenMissing && ! isset($this->ignoreUuids[$binaryUuid])) { + throw new MergeErrorDeleteMissingObject($modification, $uuid); + } + } else { + if ($exists) { + $object = IcingaObjectModification::applyModification($modification, $class::load($keyParams, $this->connection)); + // TODO: du änderst ein Objekt, und die geänderte Eigenschaften haben sich seit der Änderung geändert + $object->store($this->connection); + } elseif (! $this->ignoreModificationWhenMissing && ! isset($this->ignoreUuids[$binaryUuid])) { + throw new MergeErrorModificationForMissingObject($modification, $uuid); + } + } + } +} diff --git a/library/Director/Db/Branch/BranchModificationInspection.php b/library/Director/Db/Branch/BranchModificationInspection.php new file mode 100644 index 00000000..e2967d22 --- /dev/null +++ b/library/Director/Db/Branch/BranchModificationInspection.php @@ -0,0 +1,84 @@ +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') => 'branched_icinga_apiuser', + $this->translate('Endpoints') => 'branched_icinga_endpoint', + $this->translate('Zones') => 'branched_icinga_zone', + $this->translate('Commands') => 'branched_icinga_command', + $this->translate('Hosts') => 'branched_icinga_host', + $this->translate('Hostgroups') => 'branched_icinga_hostgroup', + $this->translate('Services') => 'branched_icinga_service', + $this->translate('Servicegroups') => 'branched_icinga_servicegroup', + $this->translate('Users') => 'branched_icinga_user', + $this->translate('Timeperiods') => 'branched_icinga_timeperiod', + ]; + + $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 = TranslationHelper::getTranslator(); + $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 created = 'y' THEN 1 ELSE 0 END)", + 'cnt_deleted' => "SUM(CASE WHEN deleted = 'y' THEN 1 ELSE 0 END)", + 'cnt_modified' => "SUM(CASE WHEN deleted = 'n' AND created = 'n' THEN 1 ELSE 0 END)", + ])->where('branch_uuid = ?', $uuid->getBytes()); + + return $this->db->fetchRow($query); + } +} diff --git a/library/Director/Db/Branch/BranchModificationStore.php b/library/Director/Db/Branch/BranchModificationStore.php new file mode 100644 index 00000000..770f520b --- /dev/null +++ b/library/Director/Db/Branch/BranchModificationStore.php @@ -0,0 +1,260 @@ +connection = $connection; + $this->shortType = $shortType; + $this->table = "branched_icinga_$shortType"; + $this->db = $connection->getDbAdapter(); + } + + public function loadAll(UuidInterface $branchUuid) + { + return $this->db->fetchAll($this->select()->where('branch_uuid = ?', $branchUuid->getBytes())); + } + + public function eventuallyLoadModification($objectId, UuidInterface $branchUuid) + { + if ($objectId) { + $row = $this->fetchOptional($objectId, $branchUuid); + } else { + return null; + } + if ($row) { + $id = (int) $objectId; + $class = DbObjectTypeRegistry::classByType($this->shortType); + if ($row->deleted === 'y') { + return ObjectModification::delete($class, $id, static::cleanupRow($row)); + } + if ($row->created === 'y') { + return ObjectModification::create($class, $row->object_name, static::cleanupRow($row)); + } + + // TODO: Former properties null? DB Problem. + return ObjectModification::modify($class, $id, null, static::filterNull(static::cleanupRow($row))); + } + + return null; + } + + public function loadOptionalModificationByName($objectName, UuidInterface $branchUuid) + { + $row = $this->fetchOptionalByName($objectName, $branchUuid); + if ($row) { + $class = DbObjectTypeRegistry::classByType($this->shortType); + if ($row->created === 'y') { + return ObjectModification::create($class, $row->object_name, static::cleanupRow($row)); + } + if ($row->deleted === 'y') { + throw new \RuntimeException('Delete for a probably non-existing object? Not sure'); + // return ObjectModification::delete($class, $row->object_name, ...); + } + + // Hint: this is not correct. Former properties are missing. We finish up here, when loading renamed objects. + return ObjectModification::modify($class, $row->object_name, [], static::filterNull(static::cleanupRow($row))); + + // TODO: better exception, handle this in the frontend + //throw new \RuntimeException('Got a modification for a probably non-existing object'); + } + + return null; + } + + protected function filterNull($row) + { + return (object) array_filter((array) $row, function ($value) { + return $value !== null; + }); + } + + protected function cleanupRow($row) + { + unset($row->object_id, $row->class, $row->branch_uuid, $row->uuid, $row->created, $row->deleted); + return $row; + } + + protected function fetchOptional($objectId, UuidInterface $branchUuid) + { + return $this->optionalRow($this->select() + ->where('object_id = ?', $objectId) + ->where('branch_uuid = ?', $branchUuid->getBytes())); + } + + protected function fetchOptionalByName($objectName, UuidInterface $branchUuid) + { + return $this->optionalRow($this->select() + ->where('object_name = ?', $objectName) + ->where('branch_uuid = ?', $branchUuid->getBytes())); + } + + protected function optionalRow($query) + { + if ($row = $this->db->fetchRow($query)) { + $this->decodeEncodedProperties($row); + return $row; + } + + return null; + } + + protected function select() + { + return $this->db->select()->from($this->table); + } + + protected function decodeEncodedProperties($row) + { + foreach (array_merge($this->encodedArrays, $this->encodedDictionaries) as $encodedProperty) { + // vars, imports and groups might be null or not set at all (if not supported) + if (! empty($row->$encodedProperty)) { + $row->$encodedProperty = Json::decode($row->$encodedProperty); + } + } + } + + protected function prepareModificationForStore(ObjectModification $modification) + { + // TODO. + } + + public function store(ObjectModification $modification, $objectId, UuidInterface $branchUuid) + { + if ($properties = $modification->getProperties()) { + $properties = (array) $properties->jsonSerialize(); + } else { + $properties = []; + } + // Former properties are not needed, as they are dealt with in persistModification. + + if ($objectId) { + $existing = $this->fetchOptional($objectId, $branchUuid); + foreach ($this->encodedDictionaries as $property) { + $this->extractFlatDictionary($properties, $existing, $property); + } + } else { + $existing = null; + } + foreach (array_merge($this->encodedArrays, $this->encodedDictionaries) as $deepProperty) { + if (isset($properties[$deepProperty])) { + // TODO: flags + $properties[$deepProperty] = Json::encode($properties[$deepProperty]); + } + } + $this->connection->runFailSafeTransaction(function () use ( + $existing, + $modification, + $objectId, + $branchUuid, + $properties + ) { + if ($existing) { + if ($modification->isDeletion()) { + $this->deleteExisting($existing->uuid); + $this->delete($objectId, $branchUuid); + } elseif ($existing->deleted === 'y') { + $this->deleteExisting($existing->uuid); + $this->create($objectId, $branchUuid, $properties); + } else { + $this->update($existing->uuid, $properties); + } + } else { + if ($modification->isCreation()) { + $this->create($objectId, $branchUuid, $properties); + } elseif ($modification->isDeletion()) { + $this->delete($objectId, $branchUuid); + } else { + $this->createModification($objectId, $branchUuid, $properties); + } + } + $activities = new BranchActivityStore($this->connection); + $activities->persistModification($modification, $branchUuid); + }); + } + + protected function extractFlatDictionary(&$properties, $existing, $prefix) + { + if ($existing && ! empty($existing->$prefix)) { + // $vars = (array) Json::decode($existing->vars); + $vars = (array) ($existing->$prefix); + } else { + $vars = []; + } + foreach ($properties as $key => $value) { + if (substr($key, 0, 5) === "$prefix.") { + $vars[substr($key, 5)] = $value; + } + } + if (! empty($vars)) { + foreach (array_keys($vars) as $key) { + unset($properties["$prefix.$key"]); + } + // This is on store only?? + $properties[$prefix] = Json::encode((object) $vars); // TODO: flags! + /// $properties['vars'] = (object) $vars; + } + } + + protected function deleteExisting($binaryUuid) + { + $this->db->delete($this->table, $this->db->quoteInto('uuid = ?', $binaryUuid)); + } + + protected function create($objectId, UuidInterface $branchUuid, $properties) + { + $this->db->insert($this->table, [ + 'branch_uuid' => $branchUuid->getBytes(), + 'uuid' => Uuid::uuid4()->getBytes(), + 'object_id' => $objectId, + 'created' => 'y', + ] + $properties); + } + + protected function delete($objectId, UuidInterface $branchUuid) + { + $this->db->insert($this->table, [ + 'branch_uuid' => $branchUuid->getBytes(), + 'uuid' => Uuid::uuid4()->getBytes(), + 'object_id' => $objectId, + 'deleted' => 'y', + ]); + } + + protected function createModification($objectId, UuidInterface $branchUuid, $properties) + { + $this->db->insert($this->table, [ + 'branch_uuid' => $branchUuid->getBytes(), + 'uuid' => Uuid::uuid4()->getBytes(), + 'object_id' => $objectId, + ] + $properties); + } + + protected function update($binaryUuid, $properties) + { + $this->db->update($this->table, [ + 'uuid' => Uuid::uuid4()->getBytes(), + ] + $properties, $this->db->quoteInto('uuid = ?', $binaryUuid)); + } +} diff --git a/library/Director/Db/Branch/IcingaObjectModification.php b/library/Director/Db/Branch/IcingaObjectModification.php new file mode 100644 index 00000000..eabf4aba --- /dev/null +++ b/library/Director/Db/Branch/IcingaObjectModification.php @@ -0,0 +1,136 @@ +shouldBeRemoved()) { + return static::delete($object); + } + + if ($object->hasBeenLoadedFromDb()) { + return static::modify($object); + } + + return static::create($object); + } + + protected static function fixForeignKeys($object) + { + // TODO: Generic, _name?? Lookup? + $keys = [ + 'check_command_name', + 'check_period_name', + 'event_command_name', + 'command_endpoint_name', + 'zone_name', + 'host_name', + ]; + + foreach ($keys as $key) { + if (property_exists($object, $key)) { + $object->{substr($key, 0, -5)} = $object->$key; + unset($object->$key); + } + } + } + + public static function applyModification(ObjectModification $modification, DbObject $object = null) + { + if ($modification->isDeletion()) { + $object->markForRemoval(); + } elseif ($modification->isCreation()) { + /** @var string|DbObject $class */ + $class = $modification->getClassName(); + $properties = $modification->getProperties()->jsonSerialize(); + self::fixForeignKeys($properties); + $object = $class::create((array) $properties); + } else { + // TODO: Add "reset Properties", those that have been nulled + $properties = (array) $modification->getProperties()->jsonSerialize(); + foreach (['vars', 'arguments'] as $property) { // TODO: define in one place, see BranchModificationStore + self::flattenProperty($properties, $property); + } + if ($object === null) { + echo '
'; + debug_print_backtrace(); + echo ''; + exit; + } + foreach ($properties as $key => $value) { + $object->set($key, $value); + } + } + + return $object; + } + + public static function delete(DbObject $object) + { + return ObjectModification::delete( + get_class($object), + self::getKey($object), + $object->toPlainObject(false, true) + ); + } + + public static function create(DbObject $object) + { + return ObjectModification::create( + get_class($object), + self::getKey($object), + $object->toPlainObject(false, true) + ); + } + + protected static function getKey(DbObject $object) + { + return $object->getKeyParams(); + } + + protected static function flattenProperty(array &$properties, $property) + { + // TODO: dots in varnames -> throw or escape? + if (isset($properties[$property])) { + foreach ($properties[$property] as $key => $value) { + $properties["$property.$key"] = $value; + } + unset($properties[$property]); + } + } + + public static function modify(DbObject $object) + { + if (! $object instanceof IcingaObject) { + throw new ProgrammingError('Plain object helpers for DbObject must be implemented'); + } + $old = (array) $object->getPlainUnmodifiedObject(); + $new = (array) $object->toPlainObject(false, true); + $unchangedKeys = []; + self::flattenProperty($old, 'vars'); + self::flattenProperty($old, 'arguments'); + self::flattenProperty($new, 'vars'); + self::flattenProperty($new, 'arguments'); + foreach ($old as $key => $value) { + if (array_key_exists($key, $new) && $value === $new[$key]) { + $unchangedKeys[] = $key; + } + } + foreach ($unchangedKeys as $key) { + unset($old[$key]); + unset($new[$key]); + } + + return ObjectModification::modify(get_class($object), self::getKey($object), $old, $new); + } +} diff --git a/library/Director/Db/Branch/MergeError.php b/library/Director/Db/Branch/MergeError.php new file mode 100644 index 00000000..331440b6 --- /dev/null +++ b/library/Director/Db/Branch/MergeError.php @@ -0,0 +1,61 @@ +modification = $modification; + $this->activityUuid = $activityUuid; + parent::__construct($this->prepareMessage()); + } + + abstract protected function prepareMessage(); + + public function getObjectTypeName() + { + /** @var string|DbObject $class */ + $class = $this->getModification()->getClassName(); + $dummy = $class::create([]); + if ($dummy instanceof IcingaObject) { + return $dummy->getShortTableName(); + } + + return $dummy->getTableName(); + } + + public function getActivityUuid() + { + return $this->activityUuid; + } + + public function getNiceObjectName() + { + $keyParams = $this->getModification()->getKeyParams(); + if (array_keys((array) $keyParams) === ['object_name']) { + return $keyParams->object_name; + } + + return json_encode($keyParams); + } + + public function getModification() + { + return $this->modification; + } +} diff --git a/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php new file mode 100644 index 00000000..71f89d14 --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php @@ -0,0 +1,15 @@ +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 00000000..fa4e724d --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php @@ -0,0 +1,15 @@ +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 00000000..0bb8c40a --- /dev/null +++ b/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php @@ -0,0 +1,15 @@ +translate('Cannot recreate %s %s'), + $this->getObjectTypeName(), + $this->getNiceObjectName() + ); + } +} diff --git a/library/Director/Db/Branch/ObjectModification.php b/library/Director/Db/Branch/ObjectModification.php new file mode 100644 index 00000000..80baae18 --- /dev/null +++ b/library/Director/Db/Branch/ObjectModification.php @@ -0,0 +1,156 @@ +class = $class; + $this->key = $key; + $this->assertValidAction($action); + $this->action = $action; + $this->properties = $properties; + $this->formerProperties = $formerProperties; + } + + public static function delete($class, $key, $formerProperties) + { + return new static( + $class, + $key, + self::ACTION_DELETE, + null, + SerializableValue::wantSerializable($formerProperties) + ); + } + + public static function create($class, $key, $properties) + { + return new static($class, $key, self::ACTION_CREATE, SerializableValue::wantSerializable($properties)); + } + + public static function modify($class, $key, $formerProperties, $properties) + { + return new static( + $class, + $key, + self::ACTION_MODIFY, + SerializableValue::wantSerializable($properties), + SerializableValue::wantSerializable($formerProperties) + ); + } + + protected function assertValidAction($action) + { + if ($action !== self::ACTION_MODIFY + && $action !== self::ACTION_CREATE + && $action !== self::ACTION_DELETE + ) { + throw new InvalidArgumentException("Valid action expected, got $action"); + } + } + + public function isDeletion() + { + return $this->action === self::ACTION_DELETE; + } + + public function isCreation() + { + return $this->action === self::ACTION_CREATE; + } + + public function isModification() + { + return $this->action === self::ACTION_MODIFY; + } + + public function getAction() + { + return $this->action; + } + + public function jsonSerialize() + { + return (object) [ + 'class' => $this->class, + 'key' => $this->key, + 'action' => $this->action, + 'properties' => $this->properties, + 'formerProperties' => $this->formerProperties, + ]; + } + + public function getProperties() + { + return $this->properties; + } + + public function getFormerProperties() + { + return $this->formerProperties; + } + + public function getClassName() + { + return $this->class; + } + + public function getKeyParams() + { + return $this->key; + } + + public static function fromSerialization($value) + { + $value = DataArrayHelper::wantArray($value); + DataArrayHelper::failOnUnknownProperties($value, self::$serializationProperties); + DataArrayHelper::requireProperties($value, ['class', 'key', 'action']); + + return new static( + $value['class'], + $value['key'], + $value['action'], + isset($value['properties']) ? SerializableValue::fromSerialization($value['properties']) : null, + isset($value['formerProperties']) ? SerializableValue::fromSerialization($value['formerProperties']) : null + ); + } +} diff --git a/library/Director/Hook/BranchSupportHook.php b/library/Director/Hook/BranchSupportHook.php new file mode 100644 index 00000000..38b36b77 --- /dev/null +++ b/library/Director/Hook/BranchSupportHook.php @@ -0,0 +1,16 @@ +getBranch()->getUuid(); + } + + protected function getBranch() + { + if ($this->branch === null) { + /** @var ActionController $this */ + $this->branch = Branch::forRequest($this->getRequest()); + } + + return $this->branch; + } + + protected function hasBranch() + { + return $this->getBranchUuid() !== null; + } +} diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php index 202a0ea6..d3258b14 100644 --- a/library/Director/Web/Controller/ObjectController.php +++ b/library/Director/Web/Controller/ObjectController.php @@ -7,6 +7,8 @@ use Icinga\Exception\IcingaException; use Icinga\Exception\InvalidPropertyException; use Icinga\Exception\NotFoundError; use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Db\Branch\Branch; use Icinga\Module\Director\Deployment\DeploymentInfo; use Icinga\Module\Director\DirectorObject\Automation\ExportInterface; use Icinga\Module\Director\Exception\NestingError; @@ -26,11 +28,13 @@ use Icinga\Module\Director\Web\Table\ActivityLogTable; use Icinga\Module\Director\Web\Table\GroupMemberTable; use Icinga\Module\Director\Web\Table\IcingaObjectDatafieldTable; use Icinga\Module\Director\Web\Tabs\ObjectTabs; +use Icinga\Module\Director\Web\Widget\ObjectModificationBranchHint; use gipfl\IcingaWeb2\Link; abstract class ObjectController extends ActionController { use ObjectRestrictions; + use BranchHelper; /** @var IcingaObject */ protected $object; @@ -90,6 +94,9 @@ abstract class ObjectController extends ActionController $this->object )); } + if ($this->object !== null) { + $this->addDeploymentLink(); + } } } @@ -456,40 +463,36 @@ abstract class ObjectController extends ActionController if ($this->object) { throw new ProgrammingError('Loading an object twice is not very efficient'); } - if ($this->object === null) { - if ($id = $this->params->get('id')) { - $this->object = IcingaObject::loadByType( - $this->getType(), - (int) $id, - $this->db() - ); - } elseif (null !== ($name = $this->params->get('name'))) { - $this->object = IcingaObject::loadByType( - $this->getType(), - $name, - $this->db() - ); - if (! $this->allowsObject($this->object)) { - $this->object = null; - throw new NotFoundError('No such object available'); - } - } elseif ($this->getRequest()->isApiRequest()) { - if ($this->getRequest()->isGet()) { - $this->getResponse()->setHttpResponseCode(422); + $isApi = $this->getRequest()->isApiRequest(); + $store = new DbObjectStore($this->db()); + if ($id = $this->params->get('id')) { + $key = (int) $id; + } elseif (null !== ($name = $this->params->get('name'))) { + $key = $name; + } + if ($key === null) { + if ($isApi && $this->getRequest()->isGet()) { + $this->getResponse()->setHttpResponseCode(422); - throw new InvalidPropertyException( - 'Cannot load object, missing parameters' - ); - } + throw new InvalidPropertyException( + 'Cannot load object, missing parameters' + ); } - if ($this->object !== null) { - $this->addDeploymentLink(); - } + return; + } + $branch = $this->getBranch(); + $store->setBranch($branch); + list($object, $modification) = $store->loadWithBranchModification(strtolower($this->getType()), $key); + if (! $this->allowsObject($object)) { + throw new NotFoundError('No such object available'); + } + if ($branch->isBranch() && ! $isApi) { + $this->content()->add(new ObjectModificationBranchHint($branch, $object, $modification)); } - return $this->object; + $this->object = $object; } protected function addDeploymentLink() @@ -499,20 +502,34 @@ abstract class ObjectController extends ActionController $info->setObject($this->object); if (! $this->getRequest()->isApiRequest()) { - $this->actions()->add( - DeploymentLinkForm::create( - $this->db(), - $info, - $this->Auth(), - $this->api() - )->handleRequest() - ); + if ($this->getBranch()->isBranch()) { + $this->actions()->add($this->linkToMergeBranch($this->getBranch())); + } else { + $this->actions()->add( + DeploymentLinkForm::create( + $this->db(), + $info, + $this->Auth(), + $this->api() + )->handleRequest() + ); + } } } catch (IcingaException $e) { // pass (deployment may not be set up yet) } } + protected function linkToMergeBranch(Branch $branch) + { + $link = Branch::requireHook()->linkToBranch($branch); + if ($link instanceof Link) { + $link->addAttributes(['class' => 'icon-flapping']); + } + + return $link; + } + protected function addBackToObjectLink() { $params = [ @@ -560,6 +577,9 @@ abstract class ObjectController extends ActionController if ($object !== null) { $form->setObject($object); } + if (true || $form->supportsBranches()) { + $form->setBranchUuid($this->getBranchUuid()); + } $this->onObjectFormLoaded($form); diff --git a/library/Director/Web/Controller/ObjectsController.php b/library/Director/Web/Controller/ObjectsController.php index 6cfea990..1db7af64 100644 --- a/library/Director/Web/Controller/ObjectsController.php +++ b/library/Director/Web/Controller/ObjectsController.php @@ -26,6 +26,8 @@ use Icinga\Module\Director\Web\Widget\AdditionalTableActions; abstract class ObjectsController extends ActionController { + use BranchHelper; + protected $isApified = true; /** @var ObjectsTable */ @@ -134,9 +136,12 @@ abstract class ObjectsController extends ActionController */ protected function getTable() { - return ObjectsTable::create($this->getType(), $this->db()) + $table = ObjectsTable::create($this->getType(), $this->db()) ->setAuth($this->getAuth()) + ->setBranchUuid($this->getBranchUuid()) ->setBaseObjectUrl($this->getBaseObjectUrl()); + + return $table; } /** diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php index f19d0eb1..13dc3963 100644 --- a/library/Director/Web/Form/DirectorObjectForm.php +++ b/library/Director/Web/Form/DirectorObjectForm.php @@ -8,6 +8,8 @@ use Icinga\Authentication\Auth; use Icinga\Module\Director\Db; use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db\Branch\BranchModificationStore; +use Icinga\Module\Director\Db\Branch\IcingaObjectModification; use Icinga\Module\Director\Exception\NestingError; use Icinga\Module\Director\Hook\IcingaObjectFormHook; use Icinga\Module\Director\IcingaConfig\StateFilterSet; @@ -18,6 +20,7 @@ use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Util; use Icinga\Module\Director\Web\Form\Element\ExtensibleSet; use Icinga\Module\Director\Web\Form\Validate\NamePattern; +use Ramsey\Uuid\UuidInterface; use Zend_Form_Element as ZfElement; use Zend_Form_Element_Select as ZfSelect; @@ -37,6 +40,9 @@ abstract class DirectorObjectForm extends DirectorForm /** @var IcingaObject */ protected $object; + /** @var UuidInterface|null */ + protected $branchUuid; + protected $objectName; protected $className; @@ -671,7 +677,23 @@ abstract class DirectorObjectForm extends DirectorForm : $this->translate('A new %s has successfully been created'), $this->translate($this->getObjectShortClassName()) ); + if ($this->branchUuid) { + if ($object->shouldBeRenamed()) { + $this->getElement('object_name')->addError( + $this->translate('Renaming objects in branches is not (yet) supported') + ); + return; + } + $store = new BranchModificationStore($this->getDb(), $object->getShortTableName()); + + $store->store( + IcingaObjectModification::getModification($object), + $object->get('id'), + $this->branchUuid + ); + } else { $object->store($this->db); + } } else { if ($this->isApiRequest()) { $this->setHttpResponseCode(304); @@ -911,7 +933,16 @@ abstract class DirectorObjectForm extends DirectorForm ); } - if ($object->delete()) { + if ($this->branchUuid) { + $store = new BranchModificationStore($this->getDb(), $object->getShortTableName()); + $store->store( + IcingaObjectModification::delete($object), + $object->get('id'), + $this->branchUuid + ); + + $this->setSuccessUrl($url); + } elseif ($object->delete()) { $this->setSuccessUrl($url); } // TODO: show object name and so @@ -1699,6 +1730,11 @@ abstract class DirectorObjectForm extends DirectorForm return Util::hasPermission($permission); } + public function setBranchUuid(UuidInterface $uuid = null) + { + $this->branchUuid = $uuid; + } + protected function allowsExperimental() { // NO, it is NOT a good idea to use this. You'll break your monitoring diff --git a/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php index 374fc516..65898a52 100644 --- a/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php +++ b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php @@ -8,6 +8,8 @@ use Icinga\Application\Icinga; use Icinga\Application\Web; use Icinga\Authentication\Auth; use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Db\Branch\BranchActivityStore; use Icinga\Module\Director\Db\Migrations; use Icinga\Module\Director\KickstartHelper; use Icinga\Module\Director\Web\Controller\Extension\DirectorDb; @@ -102,6 +104,22 @@ class ConfigHealthItemRenderer extends BadgeNavigationItemRenderer return; } + $branch = Branch::detect(); + if ($branch->isBranch()) { + $store = new BranchActivityStore($this->db()); + $count = $store->count($branch->getUuid()); + if ($count > 0) { + $this->directorState = self::STATE_PENDING; + $this->count = $count; + $this->message = sprintf( + $this->translate('%s config changes are available in your configuration branch'), + $count + ); + } + + return; + } + $pendingChanges = $db->countActivitiesSinceLastDeployedConfig(); if ($pendingChanges > 0) { diff --git a/library/Director/Web/Table/BranchActivityTable.php b/library/Director/Web/Table/BranchActivityTable.php new file mode 100644 index 00000000..4509a753 --- /dev/null +++ b/library/Director/Web/Table/BranchActivityTable.php @@ -0,0 +1,122 @@ +branchUuid = $branchUuid; + parent::__construct($db); + } + + public function assemble() + { + $this->getAttributes()->add('class', 'activity-log'); + } + + public function renderRow($row) + { + return $this->renderBranchRow($row); + } + + public function renderBranchRow($row) + { + $ts = $row->change_time / 1000; + $this->splitByDay($ts); + $changes = ObjectModification::fromSerialization(json_decode($row->change_set)); + $action = 'action-' . $changes->getAction(). ' branched'; // not gray + return $this::tr([ + $this::td($this->makeBranchLink( + $changes, + Uuid::fromBytes($row->uuid), + Uuid::fromBytes($row->branch_uuid) + ))->setSeparator(' '), + $this::td(strftime('%H:%M:%S', $ts)) + ])->addAttributes(['class' => $action]); + } + + protected function linkObject($type, $name) + { + // Later on replacing, service_set -> serviceset + + // multi column key :( + if ($type === 'service') { + return "\"$name\""; + } + + return Link::create( + "\"$name\"", + 'director/' . str_replace('_', '', $type), + ['name' => $name], + ['title' => $this->translate('Jump to this object')] + ); + } + + protected function makeBranchLink(ObjectModification $modification, UuidInterface $uuid, UuidInterface $branch) + { + /** @var string|DbObject $class */ + $class = $modification->getClassName(); + $type = $class::create([])->getShortTableName(); + // TODO: short type in table, not class name + $keyParams = $modification->getKeyParams(); + if (is_object($keyParams)) { + $keyParams = (array)$keyParams; + } + if (is_array($keyParams)) { + if (array_keys($keyParams) === ['object_name']) { + $name = $keyParams['object_name']; + } else { + $name = json_encode($keyParams); + } + } else { + $name = $keyParams; + } + $author = 'branch owner'; + + if (Util::hasPermission('director/showconfig')) { + // Later on replacing, service_set -> serviceset + $id = 0; // $row->id + + return [ + '[' . $author . ']', + Link::create( + $modification->getAction(), + 'director/branch/activity', + array_merge(['uuid' => $uuid->toString()], $this->extraParams), + ['title' => $this->translate('Show details related to this change')] + ), + str_replace('_', ' ', $type), + $this->linkObject($type, $name) + ]; + } else { + return sprintf( + '[%s] %s %s "%s"', + $author, + $modification->getAction(), + $type, + $name + ); + } + } + + public function prepareQuery() + { + return $this->db()->select()->from('director_branch_activity') + ->where('branch_uuid = ?', $this->branchUuid->getBytes()) + ->order('change_time DESC'); + } +} diff --git a/library/Director/Web/Table/ObjectsTable.php b/library/Director/Web/Table/ObjectsTable.php index f8b98be3..5712916d 100644 --- a/library/Director/Web/Table/ObjectsTable.php +++ b/library/Director/Web/Table/ObjectsTable.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Director\Web\Table; use Icinga\Authentication\Auth; use Icinga\Module\Director\Db; +use Icinga\Module\Director\Db\DbSelectParenthesis; use Icinga\Module\Director\Db\IcingaObjectFilterHelper; use Icinga\Module\Director\Objects\IcingaObject; use Icinga\Module\Director\Restriction\FilterByNameRestriction; @@ -12,6 +13,7 @@ use Icinga\Module\Director\Restriction\ObjectRestriction; use gipfl\IcingaWeb2\Link; use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; use gipfl\IcingaWeb2\Url; +use Ramsey\Uuid\UuidInterface; use Zend_Db_Select as ZfSelect; class ObjectsTable extends ZfQueryBasedTable @@ -21,6 +23,7 @@ class ObjectsTable extends ZfQueryBasedTable protected $columns = [ 'object_name' => 'o.object_name', + 'object_type' => 'o.object_type', 'disabled' => 'o.disabled', 'id' => 'o.id', ]; @@ -33,6 +36,9 @@ class ObjectsTable extends ZfQueryBasedTable protected $type; + /** @var UuidInterface|null */ + protected $branchUuid; + protected $baseObjectUrl; /** @var IcingaObject */ @@ -101,6 +107,13 @@ class ObjectsTable extends ZfQueryBasedTable return $this; } + public function setBranchUuid(UuidInterface $uuid = null) + { + $this->branchUuid = $uuid; + + return $this; + } + public function getColumns() { return $this->columns; @@ -175,8 +188,14 @@ class ObjectsTable extends ZfQueryBasedTable return []; } - protected function applyObjectTypeFilter(ZfSelect $query) + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) { + if ($right) { + $right->where( + 'bo.object_type = ?', + $this->filterObjectType + ); + } return $query->where( 'o.object_type = ?', $this->filterObjectType @@ -224,19 +243,78 @@ class ObjectsTable extends ZfQueryBasedTable return $this->dummyObject; } + protected function branchifyColumns($columns) + { + $result = []; + $ignore = ['o.id']; + foreach ($columns as $alias => $column) { + if (substr($column, 0, 2) === 'o.' && ! in_array($column, $ignore)) { + // bo.column, o.column + $column = "COALESCE(b$column, $column)"; + } + + $result[$alias] = $column; + } + + return $result; + } + + protected function stripSearchColumnAliases() + { + foreach ($this->searchColumns as &$column) { + $column = preg_replace('/^[a-z]+\./', '', $column); + } + } + protected function prepareQuery() { $table = $this->getDummyObject()->getTableName(); + $columns = $this->getColumns(); + if ($this->branchUuid) { + $columns = $this->branchifyColumns($columns); + $this->stripSearchColumnAliases(); + } $query = $this->applyRestrictions( $this->db() ->select() ->from( ['o' => $table], - $this->getColumns() + $columns ) - ->order('o.object_name')->limit(100) ); - return $this->applyObjectTypeFilter($query); + if ($this->branchUuid) { + $right = clone($query); + /** @var Db $conn */ + $conn = $this->connection(); + $query->joinLeft( + ['bo' => "branched_$table"], + // TODO: PgHexFunc + $this->db()->quoteInto( + 'bo.object_id = o.id AND bo.branch_uuid = ?', + $conn->quoteBinary($this->branchUuid->getBytes()) + ), + [] + )->where("(bo.deleted IS NULL OR bo.deleted = 'n')"); + $this->applyObjectTypeFilter($query, $right); + $right->joinRight( + ['bo' => "branched_$table"], + 'bo.object_id = o.id', + [] + ) + ->where('o.id IS NULL') + ->where('bo.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes())); + $query = $this->db()->select()->union([ + 'l' => new DbSelectParenthesis($query), + 'r' => new DbSelectParenthesis($right), + ]); + $query = $this->db()->select()->from(['u' => $query]); + $query->order('object_name')->limit(100); + } else { + $this->applyObjectTypeFilter($query); + $query->order('o.object_name')->limit(100); + } + + return $query; } } diff --git a/library/Director/Web/Table/ObjectsTableCommand.php b/library/Director/Web/Table/ObjectsTableCommand.php index 381df0d1..2208573c 100644 --- a/library/Director/Web/Table/ObjectsTableCommand.php +++ b/library/Director/Web/Table/ObjectsTableCommand.php @@ -14,6 +14,7 @@ class ObjectsTableCommand extends ObjectsTable implements FilterableByUsage protected $columns = [ 'object_name' => 'o.object_name', + 'object_type' => 'o.object_type', 'disabled' => 'o.disabled', 'command' => 'o.command', ]; @@ -58,7 +59,7 @@ class ObjectsTableCommand extends ObjectsTable implements FilterableByUsage ); } - protected function applyObjectTypeFilter(ZfSelect $query) + protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null) { return $query; } diff --git a/library/Director/Web/Widget/ObjectModificationBranchHint.php b/library/Director/Web/Widget/ObjectModificationBranchHint.php new file mode 100644 index 00000000..4045c265 --- /dev/null +++ b/library/Director/Web/Widget/ObjectModificationBranchHint.php @@ -0,0 +1,51 @@ +isBranch()) { + return; + } + $hook = Branch::requireHook(); + + if ($modification === null) { + $this->add(Hint::info($this->translate( + 'Your changes will be stored in an isolated branch. The\'ll not be part of any deployment' + . ' unless being merged' + ))); + return; + } + + if ($modification->isDeletion()) { + $this->add(Hint::info(Html::sprintf( + $this->translate('This object has been deleted in this configuration %s'), + $hook->linkToBranch($branch, $this->translate('branch')) + ))); + } elseif ($modification->isModification()) { + $this->add(Hint::info(Html::sprintf( + $this->translate('This object has %s visible only in this configuration %s'), + $hook->linkToBranchedObject($this->translate('modifications'), $branch, $object), + $hook->linkToBranch($branch, $this->translate('branch')) + ))); + } else { + $this->add(Hint::info(Html::sprintf( + $this->translate('This object has been %s in this configuration %s'), + $hook->linkToBranchedObject($this->translate('created'), $branch, $object), + $hook->linkToBranch($branch, $this->translate('branch')) + ))); + } + } +} diff --git a/public/css/module.less b/public/css/module.less index 52991ddd..fc3c604f 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -1348,6 +1348,11 @@ table.activity-log { } } + tr.branched { + background-color: @gray-lightest; + color: @color-pending; + } + tr.undeployed td:first-child::before { color: @gray; }