From bc72782dd66aef5bb52d434873f4182c0cb1b5c6 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Mon, 8 Jan 2024 21:22:48 +0100 Subject: [PATCH] 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 --- .changeset/fuzzy-comics-collect.md | 27 +++ .../README.md | 24 ++- .../api-report.md | 6 + .../config.json | 183 ++++++++++++++++++ .../package.json | 6 +- .../src/service/JsonRulesEngineFactChecker.ts | 32 ++- .../src/service/config.test.ts | 164 ++++++++++++++++ .../src/service/config.ts | 162 ++++++++++++++++ 8 files changed, 593 insertions(+), 11 deletions(-) create mode 100644 .changeset/fuzzy-comics-collect.md create mode 100644 plugins/tech-insights-backend-module-jsonfc/config.json create mode 100644 plugins/tech-insights-backend-module-jsonfc/src/service/config.test.ts create mode 100644 plugins/tech-insights-backend-module-jsonfc/src/service/config.ts diff --git a/.changeset/fuzzy-comics-collect.md b/.changeset/fuzzy-comics-collect.md new file mode 100644 index 0000000000..5ce6b3c967 --- /dev/null +++ b/.changeset/fuzzy-comics-collect.md @@ -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 +``` diff --git a/plugins/tech-insights-backend-module-jsonfc/README.md b/plugins/tech-insights-backend-module-jsonfc/README.md index 5940d39b15..3f9e91408c 100644 --- a/plugins/tech-insights-backend-module-jsonfc/README.md +++ b/plugins/tech-insights-backend-module-jsonfc/README.md @@ -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. diff --git a/plugins/tech-insights-backend-module-jsonfc/api-report.md b/plugins/tech-insights-backend-module-jsonfc/api-report.md index 5fb29d275d..d28ee28f8d 100644 --- a/plugins/tech-insights-backend-module-jsonfc/api-report.md +++ b/plugins/tech-insights-backend-module-jsonfc/api-report.md @@ -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, + ): JsonRulesEngineFactCheckerFactory; } // @public diff --git a/plugins/tech-insights-backend-module-jsonfc/config.json b/plugins/tech-insights-backend-module-jsonfc/config.json new file mode 100644 index 0000000000..b95b5e52bb --- /dev/null +++ b/plugins/tech-insights-backend-module-jsonfc/config.json @@ -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" + } + ] + } + } +} diff --git a/plugins/tech-insights-backend-module-jsonfc/package.json b/plugins/tech-insights-backend-module-jsonfc/package.json index 1c34a2157b..431a7152d4 100644 --- a/plugins/tech-insights-backend-module-jsonfc/package.json +++ b/plugins/tech-insights-backend-module-jsonfc/package.json @@ -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" } diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts index 6c1c559fc4..51609bc916 100644 --- a/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts @@ -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; private readonly operators?: Operator[]; + static fromConfig( + config: Config, + options: Omit, + ): JsonRulesEngineFactCheckerFactory { + const checks = readChecksFromConfig(config); + + return new JsonRulesEngineFactCheckerFactory({ + ...options, + checks, + }); + } + constructor(options: JsonRulesEngineFactCheckerFactoryOptions) { this.logger = options.logger; this.checks = options.checks; diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/config.test.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/config.test.ts new file mode 100644 index 0000000000..732cc7f731 --- /dev/null +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/config.test.ts @@ -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 }, + }, + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend-module-jsonfc/src/service/config.ts b/plugins/tech-insights-backend-module-jsonfc/src/service/config.ts new file mode 100644 index 0000000000..5c663d0d41 --- /dev/null +++ b/plugins/tech-insights-backend-module-jsonfc/src/service/config.ts @@ -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; + 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>(); + 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>(); + const failureMetadata = config + .getOptionalConfig('failureMetadata') + ?.get>(); + 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; +}