feat(tech-insights): define fact checks in app-config.yaml

Allow to define JSON checks in `app-config.yaml`
as `techInsights.factChecker.checks`.

Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2024-01-08 21:22:48 +01:00
parent 0269b01576
commit bc72782dd6
8 changed files with 593 additions and 11 deletions
+27
View File
@@ -0,0 +1,27 @@
---
'@backstage/plugin-tech-insights-backend-module-jsonfc': patch
---
Support loading `TechInsightsJsonRuleCheck` instances from config.
Uses the check `id` as key.
Example:
```yaml title="app-config.yaml"
techInsights:
factChecker:
checks:
groupOwnerCheck:
type: json-rules-engine
name: Group Owner Check
description: Verifies that a group has been set as the spec.owner for this entity
factIds:
- entityOwnershipFactRetriever
rule:
conditions:
all:
- fact: hasGroupOwner
operator: equal
value: true
```
@@ -46,7 +46,7 @@ By default this implementation comes with an in-memory storage to store checks.
```
## Adding checks
## Adding checks in code
Checks for this FactChecker are constructed as [`json-rules-engine` compatible JSON rules](https://github.com/CacheControl/json-rules-engine/blob/master/docs/rules.md#conditions). A check could look like the following for example:
@@ -86,6 +86,28 @@ export const exampleCheck: TechInsightJsonRuleCheck = {
};
```
## Adding checks in config
Example:
```yaml title="app-config.yaml"
techInsights:
factChecker:
checks:
groupOwnerCheck:
type: json-rules-engine
name: Group Owner Check
description: Verifies that a group has been set as the spec.owner for this entity
factIds:
- entityOwnershipFactRetriever
rule:
conditions:
all:
- fact: hasGroupOwner
operator: equal
value: true
```
### More than one `factIds` for a check.
When more than one is supplied, the requested fact **MUST** be present in at least one of the fact retrievers.
@@ -6,6 +6,7 @@
import { BooleanCheckResult } from '@backstage/plugin-tech-insights-common';
import { CheckResponse } from '@backstage/plugin-tech-insights-common';
import { CheckValidationResponse } from '@backstage/plugin-tech-insights-node';
import { Config } from '@backstage/config';
import { FactChecker } from '@backstage/plugin-tech-insights-node';
import { Logger } from 'winston';
import { Operator } from 'json-rules-engine';
@@ -63,6 +64,11 @@ export class JsonRulesEngineFactCheckerFactory {
constructor(options: JsonRulesEngineFactCheckerFactoryOptions);
// (undocumented)
construct(repository: TechInsightsStore): JsonRulesEngineFactChecker;
// (undocumented)
static fromConfig(
config: Config,
options: Omit<JsonRulesEngineFactCheckerFactoryOptions, 'checks'>,
): JsonRulesEngineFactCheckerFactory;
}
// @public
@@ -0,0 +1,183 @@
{
"$schema": "https://backstage.io/schema/config-v1",
"type": "object",
"properties": {
"techInsights": {
"type": "object",
"properties": {
"factChecker": {
"type": "object",
"properties": {
"checks": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/check"
}
}
}
}
}
}
},
"$defs": {
"check": {
"type": "object",
"required": ["type", "name", "description", "factIds", "rule"],
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"factIds": {
"type": "array",
"items": {
"type": "string"
}
},
"rule": {
"$ref": "#/$defs/rule"
}
}
},
"conditionProperties": {
"type": "object",
"required": ["fact", "operator", "value"],
"properties": {
"fact": {
"type": "string"
},
"operator": {
"type": "string"
},
"value": {
"oneOf": [
{
"type": "object",
"required": ["fact"],
"properties": {
"fact": {
"type": "string"
}
}
},
{}
]
},
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"priority": {
"type": "number"
},
"params": {
"type": "object",
"additionalProperties": true
}
}
},
"conditionReference": {
"type": "object",
"required": ["condition"],
"properties": {
"condition": {
"type": "string"
},
"name": {
"type": "string"
},
"priority": {
"type": "number"
}
}
},
"nestedCondition": {
"oneOf": [
{
"$ref": "#/$defs/conditionProperties"
},
{
"$ref": "#/$defs/topLevelCondition"
}
]
},
"rule": {
"type": "string",
"required": ["conditions"],
"properties": {
"conditions": {
"$ref": "#/$defs/topLevelCondition"
},
"name": {
"type": "string"
},
"priority": {
"type": "number"
},
"successMetadata": {
"type": "object",
"additionalProperties": true
},
"failureMetadata": {
"type": "object",
"additionalProperties": true
}
}
},
"topLevelCondition": {
"oneOf": [
{
"type": "object",
"required": ["all"],
"properties": {
"all": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/$defs/nestedCondition"
}
]
}
}
}
},
{
"type": "object",
"required": ["any"],
"properties": {
"any": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/$defs/nestedCondition"
}
]
}
}
}
},
{
"type": "object",
"required": ["not"],
"properties": {
"not": {
"$ref": "#/$defs/nestedCondition"
}
}
},
{
"$ref": "#/$defs/conditionReference"
}
]
}
}
}
@@ -34,9 +34,11 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-tech-insights-common": "workspace:^",
"@backstage/plugin-tech-insights-node": "workspace:^",
"@backstage/types": "workspace:^",
"ajv": "^8.10.0",
"json-rules-engine": "^6.1.2",
"lodash": "^4.17.21",
@@ -47,6 +49,8 @@
"@backstage/cli": "workspace:^"
},
"files": [
"config.json",
"dist"
]
],
"configSchema": "config.json"
}
@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { JsonRuleBooleanCheckResult, TechInsightJsonRuleCheck } from '../types';
import { Config } from '@backstage/config';
import { isError } from '@backstage/errors';
import { FactResponse } from '@backstage/plugin-tech-insights-common';
import {
FactChecker,
TechInsightCheckRegistry,
@@ -22,20 +24,20 @@ import {
TechInsightsStore,
CheckValidationResponse,
} from '@backstage/plugin-tech-insights-node';
import { FactResponse } from '@backstage/plugin-tech-insights-common';
import Ajv, { SchemaObject } from 'ajv';
import {
Engine,
EngineResult,
Operator,
TopLevelCondition,
} from 'json-rules-engine';
import { DefaultCheckRegistry } from './CheckRegistry';
import { Logger } from 'winston';
import { pick } from 'lodash';
import Ajv, { SchemaObject } from 'ajv';
import * as validationSchema from './validation-schema.json';
import { Logger } from 'winston';
import { JSON_RULE_ENGINE_CHECK_TYPE } from '../constants';
import { isError } from '@backstage/errors';
import { JsonRuleBooleanCheckResult, TechInsightJsonRuleCheck } from '../types';
import { DefaultCheckRegistry } from './CheckRegistry';
import { readChecksFromConfig } from './config';
import * as validationSchema from './validation-schema.json';
const noopEvent = {
type: 'noop',
@@ -340,7 +342,7 @@ export class JsonRulesEngineFactChecker
*
* Implementation of checkRegistry is optional.
* If there is a need to use persistent storage for checks, it is recommended to inject a storage implementation here.
* Otherwise an in-memory option is instantiated and used.
* Otherwise, an in-memory option is instantiated and used.
*/
export type JsonRulesEngineFactCheckerFactoryOptions = {
checks: TechInsightJsonRuleCheck[];
@@ -354,7 +356,7 @@ export type JsonRulesEngineFactCheckerFactoryOptions = {
*
* Factory to construct JsonRulesEngineFactChecker
* Can be constructed with optional implementation of CheckInsightCheckRegistry if needed.
* Otherwise defaults to using in-memory CheckRegistry
* Otherwise, defaults to using in-memory CheckRegistry.
*/
export class JsonRulesEngineFactCheckerFactory {
private readonly checks: TechInsightJsonRuleCheck[];
@@ -362,6 +364,18 @@ export class JsonRulesEngineFactCheckerFactory {
private readonly checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
private readonly operators?: Operator[];
static fromConfig(
config: Config,
options: Omit<JsonRulesEngineFactCheckerFactoryOptions, 'checks'>,
): JsonRulesEngineFactCheckerFactory {
const checks = readChecksFromConfig(config);
return new JsonRulesEngineFactCheckerFactory({
...options,
checks,
});
}
constructor(options: JsonRulesEngineFactCheckerFactoryOptions) {
this.logger = options.logger;
this.checks = options.checks;
@@ -0,0 +1,164 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { JSON_RULE_ENGINE_CHECK_TYPE } from '../constants';
import { readChecksFromConfig } from './config';
describe('config', () => {
describe('readChecksFromConfig', () => {
it('no config return empty checks array', () => {
const config = new ConfigReader({});
const checks = readChecksFromConfig(config);
expect(checks).toHaveLength(0);
});
it('empty checks config return empty checks array', () => {
const config = new ConfigReader({
techInsights: {
factChecker: {},
},
});
const checks = readChecksFromConfig(config);
expect(checks).toHaveLength(0);
});
it('with checks return parsed checks', () => {
const config = new ConfigReader({
techInsights: {
factChecker: {
checks: {
fooCheck: {
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'Foo Check',
description: 'Verifies foo',
factIds: ['fooFactRetriever'],
rule: {
conditions: {
all: [
{
fact: 'numFoo',
operator: 'greaterThanInclusive',
value: 1,
},
{
fact: 'hasFoo',
operator: 'equal',
value: true,
},
],
},
},
},
barCheck: {
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'Bar Check',
description: 'Verifies bar',
factIds: ['barFactRetriever'],
rule: {
conditions: {
any: [
{
fact: 'barEnabled',
operator: 'equal',
value: false,
},
{
fact: 'hasBar',
operator: 'equal',
value: true,
},
],
},
},
},
bazCheck: {
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'Baz Check',
description: 'Verifies baz',
factIds: ['bazFactRetriever'],
rule: {
conditions: {
not: {
fact: 'bazConfig',
operator: 'equal',
value: { invalid: true },
},
},
},
},
},
},
},
});
const checks = readChecksFromConfig(config);
expect(checks).toHaveLength(3);
expect(checks.map(check => check.id)).toEqual([
'fooCheck',
'barCheck',
'bazCheck',
]);
const fooCheck = checks.find(check => check.id === 'fooCheck')!;
expect(fooCheck.name).toEqual('Foo Check');
expect(fooCheck.rule.conditions).toEqual({
all: [
{
fact: 'numFoo',
operator: 'greaterThanInclusive',
value: 1,
},
{
fact: 'hasFoo',
operator: 'equal',
value: true,
},
],
});
const barCheck = checks.find(check => check.id === 'barCheck')!;
expect(barCheck.name).toEqual('Bar Check');
expect(barCheck.rule.conditions).toEqual({
any: [
{
fact: 'barEnabled',
operator: 'equal',
value: false,
},
{
fact: 'hasBar',
operator: 'equal',
value: true,
},
],
});
const bazCheck = checks.find(check => check.id === 'bazCheck')!;
expect(bazCheck.name).toEqual('Baz Check');
expect(bazCheck.rule.conditions).toEqual({
not: {
fact: 'bazConfig',
operator: 'equal',
value: { invalid: true },
},
});
});
});
});
@@ -0,0 +1,162 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { TopLevelCondition } from 'json-rules-engine';
import { Rule, TechInsightJsonRuleCheck } from '../types';
// copy of non-exported `ConditionProperties` from 'json-rules-engine'
interface ConditionProperties {
fact: string;
operator: string;
value: { fact: string } | any;
path?: string;
priority?: number;
params?: Record<string, any>;
name?: string;
}
// copy of non-exported `NestedCondition` from 'json-rules-engine'
type NestedCondition = ConditionProperties | TopLevelCondition;
function readRuleConditionProperties(config: Config): ConditionProperties {
const fact = config.getString('fact');
const name = config.getOptionalString('name');
const operator = config.getString('operator');
const params = config.getOptionalConfig('params')?.get<Record<string, any>>();
const path = config.getOptionalString('path');
const priority = config.getOptionalNumber('priority');
const value: { fact: string } | any = config.get('value');
return {
fact,
name,
operator,
params,
path,
priority,
value,
};
}
function readRuleNestedCondition(config: Config): NestedCondition {
if (config.has('fact')) {
return readRuleConditionProperties(config);
}
return readRuleTopLevelCondition(config);
}
function readRuleTopLevelCondition(config: Config): TopLevelCondition {
const base = {
name: config.getOptionalString('name'),
priority: config.getOptionalNumber('priority'),
};
if (config.has('all')) {
const all = config
.getConfigArray('all')
.map(conditionConfig => readRuleNestedCondition(conditionConfig));
return {
...base,
all,
};
}
if (config.has('any')) {
const any = config
.getConfigArray('any')
.map(conditionConfig => readRuleNestedCondition(conditionConfig));
return {
...base,
any,
};
}
if (config.has('not')) {
const not = readRuleNestedCondition(config.getConfig('not'));
return {
...base,
not,
};
}
const condition = config.getString('condition');
return {
...base,
condition,
};
}
function readRuleFromRuleConfig(config: Config): Rule {
const conditions = readRuleTopLevelCondition(config.getConfig('conditions'));
const name = config.getOptionalString('name');
const priority = config.getOptionalNumber('priority');
return {
conditions,
name,
priority,
};
}
function readCheckFromCheckConfig(
id: string,
config: Config,
): TechInsightJsonRuleCheck {
const type = config.getString('type');
const name = config.getString('name');
const description = config.getString('description');
const factIds = config.getStringArray('factIds');
const successMetadata = config
.getOptionalConfig('successMetadata')
?.get<Record<string, any>>();
const failureMetadata = config
.getOptionalConfig('failureMetadata')
?.get<Record<string, any>>();
const rule = readRuleFromRuleConfig(config.getConfig('rule'));
return {
description,
factIds,
failureMetadata,
id,
name,
rule,
successMetadata,
type,
};
}
export function readChecksFromConfig(
config: Config,
): TechInsightJsonRuleCheck[] {
const key = 'techInsights.factChecker.checks';
if (!config.has(key)) {
return [];
}
const checksConfig = config.getConfig(key);
const checks: TechInsightJsonRuleCheck[] = [];
checksConfig.keys().forEach(checkId => {
const checkConfig = checksConfig.getConfig(checkId);
checks.push(readCheckFromCheckConfig(checkId, checkConfig));
});
return checks;
}