Merge pull request #1860 from Icinga/feature/apply-exists

Implement AssignFilterHelper to improve apply/assign matching
This commit is contained in:
Thomas Gelf 2019-05-06 17:07:36 +02:00 committed by GitHub
commit 644853f7a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 288 additions and 6 deletions

View File

@ -0,0 +1,160 @@
<?php
namespace Icinga\Module\Director\Data;
use Icinga\Data\Filter\Filter;
use Icinga\Data\Filter\FilterAnd;
use Icinga\Data\Filter\FilterExpression;
use Icinga\Data\Filter\FilterNot;
use Icinga\Data\Filter\FilterOr;
use Icinga\Exception\NotImplementedError;
/**
* Class ApplyFilterMatches
*
* A wrapper for Icinga Filter to evaluate filters against Director's objects
*/
class AssignFilterHelper
{
/** @var Filter */
protected $filter;
public function __construct(Filter $filter)
{
$this->filter = $filter;
}
/**
* @param object $object
*
* @return bool
* @throws NotImplementedError
*/
public function matches($object)
{
return $this->matchesPart($this->filter, $object);
}
/**
* @param Filter $filter
* @param object $object
*
* @return bool
*/
public static function matchesFilter(Filter $filter, $object)
{
$helper = new static($filter);
return $helper->matches($object);
}
/**
* @param Filter $filter
* @param object $object
*
* @return bool
* @throws NotImplementedError
*/
protected function matchesPart(Filter $filter, $object)
{
if ($filter->isChain()) {
return $this->matchesChain($filter, $object);
} elseif ($filter->isExpression()) {
/** @var FilterExpression $filter */
return $this->matchesExpression($filter, $object);
} else {
return $filter->matches($object);
}
}
/**
* @param Filter $filter
* @param object $object
*
* @return bool
* @throws NotImplementedError
*/
protected function matchesChain(Filter $filter, $object)
{
if ($filter instanceof FilterAnd) {
foreach ($filter->filters() as $f) {
if (! $this->matchesPart($f, $object)) {
return false;
}
}
return true;
} elseif ($filter instanceof FilterOr) {
foreach ($filter->filters() as $f) {
if ($this->matchesPart($f, $object)) {
return true;
}
}
return false;
} elseif ($filter instanceof FilterNot) {
foreach ($filter->filters() as $f) {
if ($this->matchesPart($f, $object)) {
return false;
}
}
return true;
} else {
$class = get_class($filter);
$parts = preg_split('~\\~', $class);
throw new NotImplementedError(
'Matching for Filter of type "%s" is not implemented',
end($parts)
);
}
}
/**
* @param FilterExpression $filter
* @param object $object
*
* @return bool
*/
protected function matchesExpression(FilterExpression $filter, $object)
{
$column = $filter->getColumn();
$sign = $filter->getSign();
$expression = $filter->getExpression();
if ($sign === '=') {
if ($expression === true) {
return property_exists($object, $column) && ! empty($object->{$column});
} elseif ($expression === false) {
return ! property_exists($object, $column) || empty($object->{$column});
} elseif (is_string($expression) && strpos($expression, '*') !== false) {
if (! property_exists($object, $column) || empty($object->{$column})) {
return false;
}
$value = $object->{$column};
$parts = array();
foreach (preg_split('~\*~', $expression) as $part) {
$parts[] = preg_quote($part);
}
// match() is case insensitive
$pattern = '/^' . implode('.*', $parts) . '$/i';
if (is_array($value)) {
foreach ($value as $candidate) {
if (preg_match($pattern, $candidate)) {
return true;
}
}
return false;
}
return (bool) preg_match($pattern, $value);
}
}
// fallback to default behavior
return $filter->matches($object);
}
}

View File

@ -7,6 +7,7 @@ use Icinga\Data\Filter\Filter;
use Icinga\Data\Filter\FilterExpression;
use Icinga\Exception\ProgrammingError;
use Icinga\Module\Director\Application\MemoryLimit;
use Icinga\Module\Director\Data\AssignFilterHelper;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Db\Cache\PrefetchCache;
use stdClass;
@ -54,7 +55,7 @@ abstract class ObjectApplyMatches
{
$filterObj = static::getPreparedFilter($filter);
if ($filterObj->isExpression() || ! $filterObj->isEmpty()) {
return $filterObj->matches($this->flatObject);
return AssignFilterHelper::matchesFilter($filterObj, $this->flatObject);
} else {
return false;
}
@ -72,8 +73,10 @@ abstract class ObjectApplyMatches
Benchmark::measure(sprintf('Starting Filter %s', $filter));
$filter = clone($filter);
static::fixFilterColumns($filter);
$helper = new AssignFilterHelper($filter);
foreach (static::flatObjects($db) as $object) {
if ($filter->matches($object)) {
if ($helper->matches($object)) {
$name = $object->object_name;
$result[] = $name;
}
@ -148,6 +151,7 @@ abstract class ObjectApplyMatches
public static function fixFilterColumns(Filter $filter)
{
if ($filter->isExpression()) {
/** @var FilterExpression $filter */
static::fixFilterExpressionColumn($filter);
} else {
foreach ($filter->filters() as $sub) {
@ -164,7 +168,6 @@ abstract class ObjectApplyMatches
$filter->setColumn($column);
}
/** @var FilterExpression $filter */
$col = $filter->getColumn();
$type = static::$type;

View File

@ -79,9 +79,15 @@ class ApplyRulesTable extends ZfQueryBasedTable
}
$url = Url::fromPath("director/{$this->baseObjectUrl}/edit", $params);
$assignWhere = $this->renderApplyFilter($row->assign_filter);
if (! empty($row->apply_for)) {
$assignWhere = sprintf('apply for %s / %s', $row->apply_for, $assignWhere);
}
$tr = static::tr([
static::td(Link::create($row->object_name, $url)),
static::td($this->renderApplyFilter($row->assign_filter)),
static::td($assignWhere),
// NOT (YET) static::td($this->createActionLinks($row))->setSeparator(' ')
]);
@ -203,6 +209,7 @@ class ApplyRulesTable extends ZfQueryBasedTable
'object_name' => 'o.object_name',
'disabled' => 'o.disabled',
'assign_filter' => 'o.assign_filter',
'apply_for' => 'o.apply_for',
];
$query = $this->db()->select()->from(
['o' => $table],

View File

@ -5,6 +5,8 @@ namespace Icinga\Module\Director\Web\Table;
use dipl\Html\Html;
use Icinga\Data\DataArray\ArrayDatasource;
use Icinga\Data\Filter\Filter;
use Icinga\Exception\IcingaException;
use Icinga\Module\Director\IcingaConfig\AssignRenderer;
use Icinga\Module\Director\Objects\HostApplyMatches;
use Icinga\Module\Director\Objects\IcingaHost;
use dipl\Html\Link;
@ -96,10 +98,16 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
$link = Html::tag('a', $row->name);
}
} else {
$applyFor = '';
if (! empty($row->apply_for)) {
$applyFor = sprintf('(apply for %s) ', $row->apply_for);
}
$link = Link::create(sprintf(
$this->translate('%s (where %s)'),
$this->translate('%s %s(%s)'),
$row->name,
$row->filter
$applyFor,
$this->renderApplyFilter($row->filter)
), 'director/host/appliedservice', [
'name' => $this->host->getObjectName(),
'service_id' => $row->id,
@ -109,6 +117,22 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
return $this::row([$link], $attributes);
}
/**
* @param Filter $assignFilter
*
* @return string
*/
protected function renderApplyFilter(Filter $assignFilter)
{
try {
$string = AssignRenderer::forFilter($assignFilter)->renderAssign();
} catch (IcingaException $e) {
$string = 'Error in Filter rendering: ' . $e->getMessage();
}
return $string;
}
/**
* @return \Icinga\Data\SimpleQuery
*/
@ -130,6 +154,7 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
'disabled' => 'disabled',
'blacklisted' => 'blacklisted',
'assign_filter' => 'assign_filter',
'apply_for' => 'apply_for',
]);
}
@ -160,6 +185,7 @@ class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
'id' => 's.id',
'name' => 's.object_name',
'assign_filter' => 's.assign_filter',
'apply_for' => 's.apply_for',
'disabled' => 's.disabled',
'blacklisted' => "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END",
]

View File

@ -0,0 +1,86 @@
<?php
namespace Tests\Icinga\Module\Director\IcingaConfig;
use Icinga\Data\Filter\Filter;
use Icinga\Module\Director\Data\AssignFilterHelper;
use Icinga\Module\Director\Objects\HostApplyMatches;
use Icinga\Module\Director\Test\BaseTestCase;
class AssignFilterHelperTest extends BaseTestCase
{
protected static $exampleHost;
public static function setUpBeforeClass()
{
self::$exampleHost = (object) [
'address' => '127.0.0.1',
'vars.operatingsystem' => 'centos',
'vars.customer' => 'ACME',
'vars.roles' => ['webserver', 'mailserver'],
'vars.bool_string' => 'true',
'groups' => ['web-server', 'mail-server'],
];
}
public function testSimpleApplyFilter()
{
$this->assertFilterOutcome(true, 'host.address=true', self::$exampleHost);
$this->assertFilterOutcome(false, 'host.address=false', self::$exampleHost);
$this->assertFilterOutcome(true, 'host.address=false', (object) ['address' => null]);
$this->assertFilterOutcome(false, 'host.address=true', (object) ['address' => null]);
$this->assertFilterOutcome(true, 'host.address=%22127.0.0.%2A%22', self::$exampleHost);
}
public function testListApplyFilter()
{
$this->assertFilterOutcome(true, 'host.vars.roles=%22*server%22', self::$exampleHost);
$this->assertFilterOutcome(true, 'host.groups=%22*-server%22', self::$exampleHost);
$this->assertFilterOutcome(false, 'host.groups=%22*-nothing%22', self::$exampleHost);
}
public function testComplexApplyFilter()
{
$this->assertFilterOutcome(
true,
'host.vars.operatingsystem=%5B%22centos%22%2C%22fedora%22%5D|host.vars.osfamily=%22redhat%22',
self::$exampleHost
);
$this->assertFilterOutcome(
false,
'host.vars.operatingsystem=%5B%22centos%22%2C%22fedora%22%5D&(!(host.vars.customer=%22acme*%22))',
self::$exampleHost
);
$this->assertFilterOutcome(
true,
'!(host.vars.bool_string="false")&host.vars.operatingsystem="centos"',
self::$exampleHost
);
}
/**
* @param bool $expected
* @param string $filterQuery
* @param object $object
* @param string $message
*/
protected function assertFilterOutcome($expected, $filterQuery, $object, $message = null, $type = 'host')
{
$filter = Filter::fromQueryString($filterQuery);
if ($type === 'host') {
HostApplyMatches::fixFilterColumns($filter);
}
$helper = new AssignFilterHelper($filter);
$actual = $helper->matches($object);
if ($message === null) {
$message = sprintf('with filter "%s"', $filterQuery);
}
$this->assertEquals($expected, $actual, $message);
}
}