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:
@@ -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"
|
||||
}
|
||||
|
||||
+23
-9
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user