Branch support: initial import

This commit is contained in:
Thomas Gelf 2021-08-16 11:43:09 +02:00
parent 38d17af92e
commit 1470a134f4
25 changed files with 1793 additions and 43 deletions

View File

@ -0,0 +1,143 @@
<?php
namespace Icinga\Module\Director\Controllers;
use gipfl\Diff\HtmlRenderer\SideBySideDiff;
use gipfl\Diff\PhpDiff;
use gipfl\IcingaWeb2\Widget\NameValueTable;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Data\Json;
use Icinga\Module\Director\Db\Branch\IcingaObjectModification;
use Icinga\Module\Director\Db\Branch\ObjectModification;
use Icinga\Module\Director\IcingaConfig\IcingaConfig;
use Icinga\Module\Director\Objects\IcingaObject;
use Icinga\Module\Director\Web\Controller\ActionController;
use Icinga\Module\Director\Web\Controller\BranchHelper;
use ipl\Html\Html;
use ipl\Html\ValidHtml;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class BranchController extends ActionController
{
use BranchHelper;
protected function checkDirectorPermissions()
{
}
public function activityAction()
{
$this->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())
);
}
}

View File

@ -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');

View File

@ -0,0 +1,107 @@
<?php
namespace Icinga\Module\Director\Data\Db;
use Icinga\Exception\NotFoundError;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Db\Branch\Branch;
use Icinga\Module\Director\Db\Branch\BranchModificationStore;
use Icinga\Module\Director\Db\Branch\IcingaObjectModification;
use function in_array;
/**
* Loader for Icinga/DbObjects
*
* Is aware of branches and prefetching. I would prefer to see a StoreInterface,
* with one of the above wrapping the other. But for now, this helps to clean things
* up
*/
class DbObjectStore
{
/** @var Db */
protected $connection;
/** @var Branch */
protected $branch;
public function __construct(Db $connection)
{
$this->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];
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Application\Icinga;
use Icinga\Module\Director\Hook\BranchSupportHook;
use Icinga\Web\Hook;
use Icinga\Web\Request;
use Icinga\Web\Session\Session;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use RuntimeException;
/**
* Knows whether we're in a branch
*/
class Branch
{
/** @var UuidInterface|null */
protected $branchUuid;
/**
* @deprecated
* @param Session $session
* @return static
*/
public static function loadForSession(Session $session)
{
$self = new static();
// TODO: Load from branch if created.
$branch = $session->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;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Module\Director\Data\Json;
use Icinga\Module\Director\Db;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class BranchActivityStore
{
protected $connection;
protected $db;
protected $table = 'director_branch_activity';
public function __construct(Db $connection)
{
$this->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);
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Data\InvalidDataException;
use Icinga\Module\Director\Db;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class BranchMerger
{
/** @var Branch */
protected $branchUuid;
/** @var Db */
protected $connection;
/** @var \Zend_Db_Adapter_Abstract */
protected $db;
/** @var array */
protected $ignoreUuids = [];
/** @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 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);
}
}
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
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 $db;
public function __construct(Db $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') => '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);
}
}

View File

@ -0,0 +1,260 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
use Icinga\Module\Director\Data\Json;
use Icinga\Module\Director\Db;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class BranchModificationStore
{
protected $connection;
protected $db;
protected $shortType;
protected $table;
// TODO: Ranges is weird. key = scheduled_downtime_id, range_type, range_key
protected $encodedArrays = ['imports', 'groups', 'ranges'];
protected $encodedDictionaries = ['vars', 'arguments'];
public function __construct(Db $connection, $shortType)
{
$this->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));
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Exception\ProgrammingError;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Objects\IcingaObject;
class IcingaObjectModification
{
/**
* @param DbObject $object
* @return ObjectModification
*/
public static function getModification(DbObject $object)
{
if ($object->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 '<pre>';
debug_print_backtrace();
echo '</pre>';
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);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Exception;
use gipfl\Translation\TranslationHelper;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Objects\IcingaObject;
use Ramsey\Uuid\UuidInterface;
abstract class MergeError extends Exception
{
use TranslationHelper;
/** @var ObjectModification */
protected $modification;
/** @var UuidInterface */
protected $activityUuid;
public function __construct(ObjectModification $modification, UuidInterface $activityUuid)
{
$this->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;
}
}

View File

@ -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()
);
}
}

View File

@ -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()
);
}
}

View File

@ -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()
);
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace Icinga\Module\Director\Db\Branch;
use Icinga\Module\Director\Data\DataArrayHelper;
use Icinga\Module\Director\Data\Serializable;
use Icinga\Module\Director\Data\SerializableValue;
use InvalidArgumentException;
class ObjectModification implements Serializable
{
const ACTION_CREATE = 'create';
const ACTION_MODIFY = 'modify';
const ACTION_DELETE = 'delete';
protected static $serializationProperties = [
'class',
'key',
'action',
'properties',
'formerProperties',
];
/** @var string */
protected $class;
/** @var \stdClass */
protected $key;
/** @var string */
protected $action;
/** @var SerializableValue|null */
protected $properties;
/** @var SerializableValue|null */
protected $formerProperties;
public function __construct(
$class,
$key,
$action,
SerializableValue $properties = null,
SerializableValue $formerProperties = null
) {
$this->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
);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Icinga\Module\Director\Hook;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Db\Branch\Branch;
use Icinga\Web\Request;
abstract class BranchSupportHook
{
abstract public function getBranchForRequest(Request $request);
abstract public function linkToBranch(Branch $branch, $label = null);
abstract public function linkToBranchedObject($label, Branch $branch, DbObject $object);
}

View File

@ -0,0 +1,38 @@
<?php
namespace Icinga\Module\Director\Web\Controller;
use Icinga\Module\Director\Db\Branch\Branch;
use Ramsey\Uuid\UuidInterface;
trait BranchHelper
{
/** @var Branch */
protected $branch;
/** @var UuidInterface|null */
protected $branchUuid;
/**
* @return false|\Ramsey\Uuid\UuidInterface
*/
protected function getBranchUuid()
{
return $this->getBranch()->getUuid();
}
protected function getBranch()
{
if ($this->branch === null) {
/** @var ActionController $this */
$this->branch = Branch::forRequest($this->getRequest());
}
return $this->branch;
}
protected function hasBranch()
{
return $this->getBranchUuid() !== null;
}
}

View File

@ -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');
$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;
}
} elseif ($this->getRequest()->isApiRequest()) {
if ($this->getRequest()->isGet()) {
if ($key === null) {
if ($isApi && $this->getRequest()->isGet()) {
$this->getResponse()->setHttpResponseCode(422);
throw new InvalidPropertyException(
'Cannot load object, missing parameters'
);
}
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));
}
if ($this->object !== null) {
$this->addDeploymentLink();
}
}
return $this->object;
$this->object = $object;
}
protected function addDeploymentLink()
@ -499,6 +502,9 @@ abstract class ObjectController extends ActionController
$info->setObject($this->object);
if (! $this->getRequest()->isApiRequest()) {
if ($this->getBranch()->isBranch()) {
$this->actions()->add($this->linkToMergeBranch($this->getBranch()));
} else {
$this->actions()->add(
DeploymentLinkForm::create(
$this->db(),
@ -508,11 +514,22 @@ abstract class ObjectController extends ActionController
)->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);

View File

@ -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;
}
/**

View File

@ -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

View File

@ -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) {

View File

@ -0,0 +1,122 @@
<?php
namespace Icinga\Module\Director\Web\Table;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Db\Branch\ObjectModification;
use Icinga\Module\Director\Util;
use gipfl\IcingaWeb2\Link;
use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class BranchActivityTable extends ZfQueryBasedTable
{
protected $extraParams = [];
/** @var UuidInterface */
protected $branchUuid;
public function __construct(UuidInterface $branchUuid, $db)
{
$this->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');
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,51 @@
<?php
namespace Icinga\Module\Director\Web\Widget;
use gipfl\Translation\TranslationHelper;
use gipfl\Web\Widget\Hint;
use Icinga\Module\Director\Data\Db\DbObject;
use Icinga\Module\Director\Db\Branch\Branch;
use Icinga\Module\Director\Db\Branch\ObjectModification;
use ipl\Html\Html;
use ipl\Html\HtmlDocument;
class ObjectModificationBranchHint extends HtmlDocument
{
use TranslationHelper;
public function __construct(Branch $branch, DbObject $object, ObjectModification $modification = null)
{
if (! $branch->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'))
)));
}
}
}

View File

@ -1348,6 +1348,11 @@ table.activity-log {
}
}
tr.branched {
background-color: @gray-lightest;
color: @color-pending;
}
tr.undeployed td:first-child::before {
color: @gray;
}