Implement AssignFilterHelper to improve apply/assign matching

* is set / is not set - fixes #1483 fixes #1387
* case-insensitive matching when using wildcards '*'
  like match() in Icinga 2
This commit is contained in:
Markus Frosch 2019-05-06 14:13:20 +02:00
parent baa3ae248f
commit 4037e22709
3 changed files with 252 additions and 3 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

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