Implement Tech Insights backend
* Add common types and interfaces for Tech Insights. Exposing components needed to use and modify tech-insights-backend module and to implement individual Fact Retrievers, Fact Checkers and their persistence options. * Implement a framework to run fact retrievers and store fact data into the database. Add migration scripts to create a new database for `tech_insights`. * Create a default implementation of a FactChecker enabling users to construct checks and run them and generate scorecards based on checks. * To be able to use tech insights in your application you need to implement `FactRetriever`s to retrieve and return data for facts and register them to the tech-insights-backend. If you want to use fact checking functionality, you need to create `check`s and register them to an implementation of a `FactChecker`. For more information see documentation on the README.md files of the respective packages. Signed-off-by: Jussi Hallila <jussi@hallila.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
rules: {
|
||||
'jest/expect-expect': [
|
||||
'error',
|
||||
{
|
||||
assertFunctionNames: ['expect', 'request.**.expect'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
# @backstage/plugin-tech-insights-backend
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Initial implementation
|
||||
@@ -0,0 +1,211 @@
|
||||
# Tech Insights Backend
|
||||
|
||||
This is the backend for the default Backstage Tech Insights feature.
|
||||
This provides the API for the frontend tech insights, scorecards and fact visualization functionality,
|
||||
as well as a framework to run fact retrievers and store fact values in to a data store.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install the package
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
cd packages/backend
|
||||
yarn add @backstage/plugin-tech-insights-backend
|
||||
```
|
||||
|
||||
### Adding the plugin to your `packages/backend`
|
||||
|
||||
You'll need to add the plugin to the router in your `backend` package. You can
|
||||
do this by creating a file called `packages/backend/src/plugins/techInsights.ts`. An example content for `techInsights.ts` could be something like this.
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRouter,
|
||||
DefaultTechInsightsBuilder,
|
||||
} from '@backstage/plugin-tech-insights-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
discovery,
|
||||
database,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
const builder = new DefaultTechInsightsBuilder({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
factRetrievers: [], // Fact retrievers registrations you want tech insights to use
|
||||
});
|
||||
|
||||
return await createRouter({
|
||||
...(await builder.build()),
|
||||
logger,
|
||||
config,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
With the `techInsights.ts` router setup in place, add the router to
|
||||
`packages/backend/src/index.ts`:
|
||||
|
||||
```diff
|
||||
+import techInsights from './plugins/techInsights';
|
||||
|
||||
async function main() {
|
||||
...
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
+ const techInsightsEnv = useHotMemoize(module, () => createEnv('tech_insights'));
|
||||
|
||||
const apiRouter = Router();
|
||||
+ apiRouter.use('/tech-insights', await techInsights(techInsightsEnv));
|
||||
...
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
```
|
||||
|
||||
### Adding fact retrievers
|
||||
|
||||
At this point the Tech Insights backend is installed in your backend package, but
|
||||
you will not have any fact retrievers present in your application. To have the implemented FactRetrieverEngine within this package to be able to retrieve and store fact data into the database, you need to add these.
|
||||
|
||||
To create factRetrieverRegistration you need to implement `FactRetriever` interface defined in `@backstage/plugin-tech-insights-common` package. After you have implemented this interface you can wrap that into a registration object like follows:
|
||||
|
||||
```ts
|
||||
const myFactRetriever: FactRetriever = {
|
||||
/**
|
||||
* snip
|
||||
*/
|
||||
};
|
||||
|
||||
const myFactRetrieverRegistration = {
|
||||
cadence: '1 * 3 * * ', // On the first minute of the third day of the month
|
||||
factRetriever: myFactRetriever,
|
||||
};
|
||||
```
|
||||
|
||||
Then you can modify the example `techInsights.ts` file shown above like this:
|
||||
|
||||
```diff
|
||||
const builder = new DefaultTechInsightsBuilder({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
- factRetrievers: [],
|
||||
+ factRetrievers: [myFactRetrieverRegistration],
|
||||
});
|
||||
```
|
||||
|
||||
### Creating Fact Retrievers
|
||||
|
||||
A Fact Retriever consist of three parts:
|
||||
|
||||
1. `ref` - unique identifier of a fact retriever
|
||||
2. `schema` - A versioned schema defining the shape of data a fact retriever returns
|
||||
3. `handler` - An asynchronous function handling the logic of retrieving and returning facts for an entity
|
||||
|
||||
An example implementation of a FactRetriever could for example be as follows:
|
||||
|
||||
```ts
|
||||
const myFactRetriever: FactRetriever = {
|
||||
ref: 'documentation-number-factretriever', // unique ref, identifier of the fact retriever
|
||||
schema: {
|
||||
version: '0.1.1', // SemVer version number of this fact retriever schema. This should be incremented if the implementation changes
|
||||
|
||||
// the actual schema
|
||||
schema: {
|
||||
// Name/identifier of an individual fact that this retriever returns
|
||||
examplenumberfact: {
|
||||
type: 'integer', // Type of the fact
|
||||
description: 'A fact of a number', // Description of the fact
|
||||
entityKinds: ['component'], // An array of entity kinds that this fact is applicable to
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async ctx => {
|
||||
// Handler function that retrieves the fact
|
||||
const { discovery, config, logger } = ctx;
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
const entities = await catalogClient.getEntities(); // Retrieve all entities
|
||||
/**
|
||||
* snip: Do complex logic to retrieve facts from external system or calculate fact values
|
||||
*/
|
||||
|
||||
// Respond with an array of entity/fact values
|
||||
return entities.items.map(it => {
|
||||
return {
|
||||
// Entity information that this fact relates to
|
||||
entity: {
|
||||
namespace: it.metadata.namespace,
|
||||
kind: it.kind,
|
||||
name: it.metadata.name,
|
||||
},
|
||||
|
||||
// All facts that this retriever returns
|
||||
facts: {
|
||||
examplenumberfact: 2, //
|
||||
},
|
||||
// (optional) timestamp to use as a Luxon DateTime object
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Adding a fact checker
|
||||
|
||||
This module comes with a possibility to additionally add a fact checker and expose fact checking endpoints from the API. To be able to enable this feature you need to add a FactCheckerFactory implementation to be part of the `DefaultTechInsightsBuilder` constructor call.
|
||||
|
||||
There is a default FactChecker implementation provided in module `@backstage/plugin-tech-insights-backend-module-jsonfc`. This implementation uses `json-rules-engine` as the underlying functionality to run checks. If you want to implement your own FactChecker, for example to be able to handle other than `boolean` result types, you can do so by implementing `FactCheckerFactory` and `FactChecker` interfaces from `@backstage/plugin-tech-insights-common` package.
|
||||
|
||||
To add the default FactChecker into your Tech Insights you need to install the module into your backend application:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
cd packages/backend
|
||||
yarn add @backstage/plugin-tech-insights-backend-module-jsonfc
|
||||
```
|
||||
|
||||
and modify the `techInsights.ts` file to contain a reference to the FactChecker implementation.
|
||||
|
||||
```diff
|
||||
+ import { JsonRulesEngineFactCheckerFactory } from '@backstage/plugin-tech-insights-backend-module-jsonfc';
|
||||
|
||||
+ const myFactCheckerFactory = new JsonRulesEngineFactCheckerFactory({
|
||||
+ checks: [],
|
||||
+ logger,
|
||||
+ }),
|
||||
|
||||
const builder = new DefaultTechInsightsBuilder({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
discovery,
|
||||
factRetrievers: [myFactRetrieverRegistration],
|
||||
+ factCheckerFactory: myFactCheckerFactory
|
||||
});
|
||||
```
|
||||
|
||||
To be able to run checks, you need to additionally add individual checks into your FactChecker implementation. For examples how to add these, you can check the documentation of the individual implementation of the FactChecker
|
||||
|
||||
#### Modifying check persistence
|
||||
|
||||
The default FactChecker implementation comes with an in-memory storage to store checks. You can inject an additional data store by adding an implementation of `TechInsightCheckRegistry` into the constructor options when creating a `JsonRulesEngineFactCheckerFactory`. That can be done as follows:
|
||||
|
||||
```diff
|
||||
const myTechInsightCheckRegistry: TechInsightCheckRegistry<MyCheckType> = // snip
|
||||
const myFactCheckerFactory = new JsonRulesEngineFactCheckerFactory({
|
||||
checks: [],
|
||||
logger,
|
||||
+ checkRegistry: myTechInsightCheckRegistry
|
||||
}),
|
||||
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
## API Report File for "@backstage/plugin-tech-insights-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { CheckResult } from '@backstage/plugin-tech-insights-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { FactChecker } from '@backstage/plugin-tech-insights-common';
|
||||
import { FactCheckerFactory } from '@backstage/plugin-tech-insights-common';
|
||||
import { FactRetrieverRegistration } from '@backstage/plugin-tech-insights-common';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { TechInsightCheck } from '@backstage/plugin-tech-insights-common';
|
||||
import { TechInsightsStore } from '@backstage/plugin-tech-insights-common';
|
||||
|
||||
// @public
|
||||
export function createRouter<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
>(options: RouterOptions<CheckType, CheckResultType>): Promise<express.Router>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class DefaultTechInsightsBuilder<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
constructor(options: TechInsightsOptions<CheckType, CheckResultType>);
|
||||
build(): Promise<TechInsightsContext<CheckType, CheckResultType>>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PersistenceContext = {
|
||||
techInsightsStore: TechInsightsStore;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface RouterOptions<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
config: Config;
|
||||
factChecker?: FactChecker<CheckType, CheckResultType>;
|
||||
logger: Logger_2;
|
||||
persistenceContext: PersistenceContext;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type TechInsightsContext<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> = {
|
||||
factChecker?: FactChecker<CheckType, CheckResultType>;
|
||||
persistenceContext: PersistenceContext;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface TechInsightsOptions<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
database: PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
factCheckerFactory?: FactCheckerFactory<CheckType, CheckResultType>;
|
||||
factRetrievers: FactRetrieverRegistration[];
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.createTable('fact_schemas', table => {
|
||||
table.comment(
|
||||
'The table for tech insight fact schemas. Containing a versioned data model definition for a collection of facts.',
|
||||
);
|
||||
table.increments('id').primary();
|
||||
table
|
||||
.text('ref')
|
||||
.notNullable()
|
||||
.comment('Identifier of the fact retriever plugin/package');
|
||||
table
|
||||
.string('version')
|
||||
.notNullable()
|
||||
.comment('SemVer string defining the version of schema.');
|
||||
table
|
||||
.text('schema')
|
||||
.notNullable()
|
||||
.comment(
|
||||
'Fact schema defining the values/types what this version of the fact would contain.',
|
||||
);
|
||||
|
||||
table.index('ref', 'fact_schema_ref_idx');
|
||||
table.index(['ref', 'version'], 'fact_schema_ref_version_idx');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.alterTable('fact_schemas', table => {
|
||||
table.dropIndex([], 'fact_schema_ref_idx');
|
||||
table.dropIndex([], 'fact_schema_ref_version_idx');
|
||||
});
|
||||
await knex.schema.dropTable('fact_schemas');
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.createTable('facts', table => {
|
||||
table.comment(
|
||||
'The table for tech insight fact collections. Contains facts for individual fact retriever namespace/ref.',
|
||||
);
|
||||
table
|
||||
.bigIncrements('index')
|
||||
.notNullable()
|
||||
.comment('An insert counter to ensure ordering');
|
||||
table
|
||||
.text('ref')
|
||||
.notNullable()
|
||||
.comment('Unique identifier of the fact retriever plugin/package');
|
||||
table
|
||||
.string('version')
|
||||
.notNullable()
|
||||
.comment(
|
||||
'SemVer string defining the version of schema this fact is based on.',
|
||||
);
|
||||
table
|
||||
.dateTime('timestamp')
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
.comment('The timestamp when this entry was created');
|
||||
table
|
||||
.text('entity')
|
||||
.notNullable()
|
||||
.comment('Identifier of the entity these facts relate to');
|
||||
table
|
||||
.text('facts')
|
||||
.notNullable()
|
||||
.comment(
|
||||
'Values of the fact collection stored as key-value pairs in JSON format.',
|
||||
);
|
||||
|
||||
table.index('index', 'fact_index_idx');
|
||||
table.index('ref', 'fact_ref_idx');
|
||||
table.index(['ref', 'entity'], 'fact_ref_entity_idx');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.alterTable('facts', table => {
|
||||
table.dropIndex([], 'facts_index_idx');
|
||||
table.dropIndex([], 'fact_ref_idx');
|
||||
table.dropIndex([], 'fact_ref_entity_idx');
|
||||
});
|
||||
await knex.schema.dropTable('facts');
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@backstage/plugin-tech-insights-backend",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/tech-insights-backend"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"tech-insights",
|
||||
"reporting"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.0",
|
||||
"@backstage/catalog-client": "^0.3.16",
|
||||
"@backstage/catalog-model": "^0.9.0",
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/plugin-tech-insights-common": "^0.1.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"knex": "^0.95.1",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"node-cron": "^3.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/node-cron": "^3.0.0",
|
||||
"@types/semver": "^7.3.8",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
export * from './service/router';
|
||||
export type { RouterOptions } from './service/router';
|
||||
|
||||
export { DefaultTechInsightsBuilder } from './service/DefaultTechInsightsBuilder';
|
||||
export type {
|
||||
TechInsightsOptions,
|
||||
TechInsightsContext,
|
||||
} from './service/DefaultTechInsightsBuilder';
|
||||
|
||||
export type { PersistenceContext } from './service/persistence/DatabaseManager';
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2021 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 { FactRetrieverEngine } from './fact/FactRetrieverEngine';
|
||||
import { Logger } from 'winston';
|
||||
import { FactRetrieverRegistry } from './fact/FactRetrieverRegistry';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
PluginDatabaseManager,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import {
|
||||
CheckResult,
|
||||
FactChecker,
|
||||
FactCheckerFactory,
|
||||
FactRetrieverRegistration,
|
||||
TechInsightCheck,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import {
|
||||
DatabaseManager,
|
||||
PersistenceContext,
|
||||
} from './persistence/DatabaseManager';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @typeParam CheckType - Type of the check for the fact checker this builder returns
|
||||
* @typeParam CheckResultType - Type of the check result for the fact checker this builder returns
|
||||
*
|
||||
* Configuration options to initialize TechInsightsBuilder. Generic types params are needed if FactCheckerFactory
|
||||
* is included for FactChecker creation.
|
||||
*/
|
||||
export interface TechInsightsOptions<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
/**
|
||||
* A collection of FactRetrieverRegistrations.
|
||||
* Used to register FactRetrievers and their schemas and schedule an execution loop for them.
|
||||
*/
|
||||
factRetrievers: FactRetrieverRegistration[];
|
||||
|
||||
/**
|
||||
* Optional factory exposing a `construct` method to initialize a FactChecker implementation
|
||||
*/
|
||||
factCheckerFactory?: FactCheckerFactory<CheckType, CheckResultType>;
|
||||
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
database: PluginDatabaseManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @typeParam CheckType - Type of the check for the fact checker this builder returns
|
||||
* @typeParam CheckResultType - Type of the check result for the fact checker this builder returns
|
||||
*
|
||||
* A container for exported implementations related to TechInsights.
|
||||
* FactChecker is present if an optional FactCheckerFactory is included in the build stage.
|
||||
*/
|
||||
export type TechInsightsContext<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> = {
|
||||
factChecker?: FactChecker<CheckType, CheckResultType>;
|
||||
persistenceContext: PersistenceContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @typeParam CheckType - Type of the check for the fact checker this builder returns
|
||||
* @typeParam CheckResultType - Type of the check result for the fact checker this builder returns
|
||||
*
|
||||
* Default implementation of TechInsightsBuilder.
|
||||
*/
|
||||
export class DefaultTechInsightsBuilder<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
private readonly options: TechInsightsOptions<CheckType, CheckResultType>;
|
||||
|
||||
constructor(options: TechInsightsOptions<CheckType, CheckResultType>) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs needed persistence context, fact retriever engine
|
||||
* and optionally fact checker implementations to be used in the tech insights module.
|
||||
*
|
||||
* @returns TechInsightsContext with persistence implementations and optionally an implementation of a FactChecker
|
||||
*/
|
||||
async build(): Promise<TechInsightsContext<CheckType, CheckResultType>> {
|
||||
const {
|
||||
factRetrievers,
|
||||
factCheckerFactory,
|
||||
config,
|
||||
discovery,
|
||||
database,
|
||||
logger,
|
||||
} = this.options;
|
||||
|
||||
const factRetrieverRegistry = new FactRetrieverRegistry(factRetrievers);
|
||||
|
||||
const persistenceContext =
|
||||
await DatabaseManager.initializePersistenceContext(
|
||||
await database.getClient(),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
const factRetrieverEngine = await FactRetrieverEngine.fromConfig({
|
||||
repository: persistenceContext.techInsightsStore,
|
||||
factRetrieverRegistry,
|
||||
factRetrieverContext: {
|
||||
config,
|
||||
discovery,
|
||||
logger,
|
||||
},
|
||||
});
|
||||
|
||||
factRetrieverEngine.schedule();
|
||||
|
||||
if (factCheckerFactory) {
|
||||
const factChecker = factCheckerFactory.construct(
|
||||
persistenceContext.techInsightsStore,
|
||||
);
|
||||
return {
|
||||
persistenceContext,
|
||||
factChecker,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
persistenceContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
FactRetriever,
|
||||
FactRetrieverRegistration,
|
||||
FactSchema,
|
||||
TechInsightFact,
|
||||
TechInsightsStore,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import { FactRetrieverRegistry } from './FactRetrieverRegistry';
|
||||
import { FactRetrieverEngine } from './FactRetrieverEngine';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { schedule } from 'node-cron';
|
||||
|
||||
jest.mock('node-cron', () => {
|
||||
const original = jest.requireActual('node-cron');
|
||||
return {
|
||||
...original,
|
||||
schedule: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const testFactRetriever: FactRetriever = {
|
||||
ref: 'test-factretriever',
|
||||
schema: {
|
||||
version: '0.0.1',
|
||||
schema: {
|
||||
testnumberfact: {
|
||||
type: 'integer',
|
||||
description: '',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async () => {
|
||||
return [
|
||||
{
|
||||
ref: 'test-factretriever',
|
||||
entity: {
|
||||
namespace: 'a',
|
||||
kind: 'a',
|
||||
name: 'a',
|
||||
},
|
||||
facts: {
|
||||
testnumberfact: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const cadence = '1 * * * *';
|
||||
describe('FactRetrieverEngine', () => {
|
||||
let engine: FactRetrieverEngine;
|
||||
let factSchemaAssertionCallback: (ref: string, schema: FactSchema) => void;
|
||||
let factInsertionAssertionCallback: (facts: TechInsightFact[]) => void;
|
||||
|
||||
const mockRepository: TechInsightsStore = {
|
||||
insertFacts: (facts: TechInsightFact[]) => {
|
||||
factInsertionAssertionCallback(facts);
|
||||
return Promise.resolve();
|
||||
},
|
||||
insertFactSchema: (ref: string, schema: FactSchema) => {
|
||||
factSchemaAssertionCallback(ref, schema);
|
||||
return Promise.resolve();
|
||||
},
|
||||
} as unknown as TechInsightsStore;
|
||||
|
||||
const mockFactRetrieverRegistry: FactRetrieverRegistry = {
|
||||
listRetrievers(): FactRetriever[] {
|
||||
return [testFactRetriever];
|
||||
},
|
||||
listRegistrations(): FactRetrieverRegistration[] {
|
||||
return [{ factRetriever: testFactRetriever, cadence }];
|
||||
},
|
||||
} as unknown as FactRetrieverRegistry;
|
||||
|
||||
const defaultEngineConfig = {
|
||||
factRetrieverContext: {
|
||||
logger: getVoidLogger(),
|
||||
config: ConfigReader.fromConfigs([]),
|
||||
discovery: {
|
||||
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
|
||||
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
|
||||
},
|
||||
},
|
||||
factRetrieverRegistry: mockFactRetrieverRegistry,
|
||||
repository: mockRepository,
|
||||
};
|
||||
|
||||
it('Should update fact retriever schemas on initialization', async () => {
|
||||
factSchemaAssertionCallback = (ref, schema) => {
|
||||
expect(ref).toEqual('test-factretriever');
|
||||
expect(schema).toEqual({
|
||||
version: '0.0.1',
|
||||
schema: {
|
||||
testnumberfact: {
|
||||
type: 'integer',
|
||||
description: '',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
engine = await FactRetrieverEngine.fromConfig(defaultEngineConfig);
|
||||
});
|
||||
it('Should insert facts when scheduled step is run', async () => {
|
||||
(schedule as jest.Mock).mockImplementation(
|
||||
(cronCadence: string, retrieverAction: Function) => {
|
||||
return {
|
||||
cadence: cronCadence,
|
||||
triggerScheduledJobNow: retrieverAction,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
factSchemaAssertionCallback = () => {};
|
||||
factInsertionAssertionCallback = facts => {
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0]).toEqual({
|
||||
ref: 'test-factretriever',
|
||||
entity: {
|
||||
namespace: 'a',
|
||||
kind: 'a',
|
||||
name: 'a',
|
||||
},
|
||||
facts: {
|
||||
testnumberfact: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
engine = await FactRetrieverEngine.fromConfig(defaultEngineConfig);
|
||||
engine.schedule();
|
||||
const job: any = engine.getJob('test-factretriever');
|
||||
job.triggerScheduledJobNow();
|
||||
expect(job.cadence!!).toEqual(cadence);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
FactRetriever,
|
||||
FactRetrieverContext,
|
||||
TechInsightsStore,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import { FactRetrieverRegistry } from './FactRetrieverRegistry';
|
||||
import { schedule, validate, ScheduledTask } from 'node-cron';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
function randomDailyCron() {
|
||||
const rand = (min: number, max: number) =>
|
||||
Math.floor(Math.random() * (max - min + 1) + min);
|
||||
return `${rand(0, 59)} ${rand(0, 23)} * * *`;
|
||||
}
|
||||
|
||||
function duration(startTimestamp: [number, number]): string {
|
||||
const delta = process.hrtime(startTimestamp);
|
||||
const seconds = delta[0] + delta[1] / 1e9;
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export class FactRetrieverEngine {
|
||||
private scheduledJobs = new Map<string, ScheduledTask>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: TechInsightsStore,
|
||||
private readonly factRetrieverRegistry: FactRetrieverRegistry,
|
||||
private readonly factRetrieverContext: FactRetrieverContext,
|
||||
private readonly logger: Logger,
|
||||
private readonly defaultCadence?: string,
|
||||
) {}
|
||||
|
||||
static async fromConfig({
|
||||
repository,
|
||||
factRetrieverRegistry,
|
||||
factRetrieverContext,
|
||||
defaultCadence,
|
||||
}: {
|
||||
repository: TechInsightsStore;
|
||||
factRetrieverRegistry: FactRetrieverRegistry;
|
||||
factRetrieverContext: FactRetrieverContext;
|
||||
defaultCadence?: string;
|
||||
}) {
|
||||
await Promise.all(
|
||||
factRetrieverRegistry
|
||||
.listRetrievers()
|
||||
.map(it => repository.insertFactSchema(it.ref, it.schema)),
|
||||
);
|
||||
|
||||
return new FactRetrieverEngine(
|
||||
repository,
|
||||
factRetrieverRegistry,
|
||||
factRetrieverContext,
|
||||
factRetrieverContext.logger,
|
||||
defaultCadence,
|
||||
);
|
||||
}
|
||||
|
||||
schedule() {
|
||||
const registrations = this.factRetrieverRegistry.listRegistrations();
|
||||
const newRegs: string[] = [];
|
||||
registrations.forEach(registration => {
|
||||
const { factRetriever, cadence } = registration;
|
||||
if (!this.scheduledJobs.has(factRetriever.ref)) {
|
||||
const cronExpression =
|
||||
cadence || this.defaultCadence || randomDailyCron();
|
||||
if (!validate(cronExpression)) {
|
||||
this.logger.warn(
|
||||
`Validation failed for cron expression ${cronExpression} when trying to schedule fact retriever ${factRetriever.ref}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const job = schedule(
|
||||
cronExpression,
|
||||
this.createFactRetrieverHandler(factRetriever),
|
||||
);
|
||||
this.scheduledJobs.set(factRetriever.ref, job);
|
||||
newRegs.push(factRetriever.ref);
|
||||
}
|
||||
});
|
||||
this.logger.info(
|
||||
`Scheduled ${newRegs.length} fact retrievers to Fact Retriever Engine.`,
|
||||
);
|
||||
}
|
||||
|
||||
getJob(ref: string) {
|
||||
return this.scheduledJobs.get(ref);
|
||||
}
|
||||
|
||||
private createFactRetrieverHandler(factRetriever: FactRetriever) {
|
||||
return async () => {
|
||||
const startTimestamp = process.hrtime();
|
||||
this.logger.info(
|
||||
`Retrieving facts for fact retriever ${factRetriever.ref}`,
|
||||
);
|
||||
const facts = await factRetriever.handler(this.factRetrieverContext);
|
||||
if (this.logger.isDebugEnabled()) {
|
||||
this.logger.debug(
|
||||
`Retrieved ${facts.length} facts for fact retriever ${
|
||||
factRetriever.ref
|
||||
} in ${duration(startTimestamp)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.repository.insertFacts(factRetriever.ref, facts);
|
||||
this.logger.info(
|
||||
`Stored ${facts.length} facts for fact retriever ${
|
||||
factRetriever.ref
|
||||
} in ${duration(startTimestamp)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Failed to insert facts for fact retriever ${factRetriever.ref}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
FactRetriever,
|
||||
FactRetrieverRegistration,
|
||||
FactSchema,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
|
||||
export class FactRetrieverRegistry {
|
||||
private readonly retrievers = new Map<string, FactRetrieverRegistration>();
|
||||
|
||||
constructor(retrievers: FactRetrieverRegistration[]) {
|
||||
retrievers.forEach(it => {
|
||||
this.register(it);
|
||||
});
|
||||
}
|
||||
|
||||
register(registration: FactRetrieverRegistration) {
|
||||
if (this.retrievers.has(registration.factRetriever.ref)) {
|
||||
throw new ConflictError(
|
||||
`Tech insight fact retriever with reference '${registration.factRetriever.ref}' has already been registered`,
|
||||
);
|
||||
}
|
||||
this.retrievers.set(registration.factRetriever.ref, registration);
|
||||
}
|
||||
|
||||
get(retrieverReference: string): FactRetriever {
|
||||
const registration = this.retrievers.get(retrieverReference);
|
||||
if (!registration) {
|
||||
throw new NotFoundError(
|
||||
`Tech insight fact retriever with reference '${retrieverReference}' is not registered.`,
|
||||
);
|
||||
}
|
||||
return registration.factRetriever;
|
||||
}
|
||||
|
||||
listRetrievers(): FactRetriever[] {
|
||||
return [...this.retrievers.values()].map(it => it.factRetriever);
|
||||
}
|
||||
|
||||
listRegistrations(): FactRetrieverRegistration[] {
|
||||
return [...this.retrievers.values()];
|
||||
}
|
||||
|
||||
getSchemas(): FactSchema[] {
|
||||
return this.listRetrievers().map(it => it.schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2021 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 { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
|
||||
import knexFactory, { Knex } from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TechInsightsDatabase } from './TechInsightsDatabase';
|
||||
import { TechInsightsStore } from '@backstage/plugin-tech-insights-common';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-tech-insights-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
/**
|
||||
* A Container for persistence related components in TechInsights
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PersistenceContext = {
|
||||
techInsightsStore: TechInsightsStore;
|
||||
};
|
||||
|
||||
export type CreateDatabaseOptions = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
const defaultOptions: CreateDatabaseOptions = {
|
||||
logger: getVoidLogger(),
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory class to construct persistence context for both running implmentation and test cases.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class DatabaseManager {
|
||||
public static async initializePersistenceContext(
|
||||
knex: Knex,
|
||||
options: CreateDatabaseOptions = defaultOptions,
|
||||
): Promise<PersistenceContext> {
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
return {
|
||||
techInsightsStore: new TechInsightsDatabase(knex, options.logger),
|
||||
};
|
||||
}
|
||||
|
||||
public static async createTestDatabase(
|
||||
knex: Knex,
|
||||
): Promise<PersistenceContext> {
|
||||
const knexInstance = knex ?? (await this.createTestDatabaseConnection());
|
||||
return await this.initializePersistenceContext(knexInstance);
|
||||
}
|
||||
|
||||
public static async createTestDatabaseConnection(): Promise<Knex> {
|
||||
const config: Knex.Config = {
|
||||
client: 'sqlite3',
|
||||
connection: ':memory:',
|
||||
useNullAsDefault: true,
|
||||
};
|
||||
|
||||
let knexInstance = knexFactory(config);
|
||||
if (typeof config.connection !== 'string') {
|
||||
const tempDbName = `d${uuidv4().replace(/-/g, '')}`;
|
||||
await knexInstance.raw(`CREATE DATABASE ${tempDbName};`);
|
||||
knexInstance = knexFactory({
|
||||
...config,
|
||||
connection: {
|
||||
...config.connection,
|
||||
database: tempDbName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
knexInstance.client.pool.on(
|
||||
'createSuccess',
|
||||
(_eventId: any, resource: any) => {
|
||||
resource.run('PRAGMA foreign_keys = ON', () => {});
|
||||
},
|
||||
);
|
||||
|
||||
return knexInstance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright 2021 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 { DatabaseManager } from './DatabaseManager';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { TechInsightsStore } from '@backstage/plugin-tech-insights-common';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
const factSchemas = [
|
||||
{
|
||||
ref: 'test-schema',
|
||||
version: '0.0.1-test',
|
||||
schema: JSON.stringify({
|
||||
testNumberFact: {
|
||||
type: 'integer',
|
||||
description: 'Test fact with a number type',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
const additionalFactSchemas = [
|
||||
{
|
||||
ref: 'test-schema',
|
||||
version: '1.2.1-test',
|
||||
schema: JSON.stringify({
|
||||
testNumberFact: {
|
||||
type: 'integer',
|
||||
description: 'Test fact with a number type',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
testStringFact: {
|
||||
type: 'string',
|
||||
description: 'Test fact with a string type',
|
||||
entityKinds: ['service'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
ref: 'test-schema',
|
||||
version: '1.1.1-test',
|
||||
schema: JSON.stringify({
|
||||
testStringFact: {
|
||||
type: 'string',
|
||||
description: 'Test fact with a string type',
|
||||
entityKinds: ['service'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const now = DateTime.now().toISO();
|
||||
const shortlyInTheFuture = DateTime.now()
|
||||
.plus(Duration.fromMillis(555))
|
||||
.toISO();
|
||||
const farInTheFuture = DateTime.now()
|
||||
.plus(Duration.fromMillis(555666777))
|
||||
.toISO();
|
||||
|
||||
const facts = [
|
||||
{
|
||||
timestamp: now,
|
||||
ref: 'test-fact',
|
||||
version: '0.0.1-test',
|
||||
entity: 'a/a/a',
|
||||
facts: JSON.stringify({
|
||||
testNumberFact: 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
timestamp: shortlyInTheFuture,
|
||||
ref: 'test-fact',
|
||||
version: '0.0.1-test',
|
||||
entity: 'a/a/a',
|
||||
facts: JSON.stringify({
|
||||
testNumberFact: 2,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const additionalFacts = [
|
||||
{
|
||||
timestamp: farInTheFuture,
|
||||
ref: 'test-fact',
|
||||
version: '0.0.1-test',
|
||||
entity: 'a/a/a',
|
||||
facts: JSON.stringify({
|
||||
testNumberFact: 3,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe('Tech Insights database', () => {
|
||||
let store: TechInsightsStore;
|
||||
let testDbClient: Knex<any, unknown[]>;
|
||||
beforeAll(async () => {
|
||||
testDbClient = await DatabaseManager.createTestDatabaseConnection();
|
||||
store = (await DatabaseManager.createTestDatabase(testDbClient))
|
||||
.techInsightsStore;
|
||||
await testDbClient.batchInsert('fact_schemas', factSchemas);
|
||||
await testDbClient.batchInsert('facts', facts);
|
||||
});
|
||||
|
||||
const baseAssertionFact = {
|
||||
ref: 'test-fact',
|
||||
entity: { namespace: 'a', kind: 'a', name: 'a' },
|
||||
timestamp: DateTime.fromISO(shortlyInTheFuture),
|
||||
version: '0.0.1-test',
|
||||
facts: { testNumberFact: 2 },
|
||||
};
|
||||
|
||||
it('should be able to return latest schema', async () => {
|
||||
const schemas = await store.getLatestSchemas();
|
||||
expect(schemas[0]).toMatchObject({
|
||||
ref: 'test-schema',
|
||||
version: '0.0.1-test',
|
||||
schema: {
|
||||
testNumberFact: {
|
||||
type: 'integer',
|
||||
description: 'Test fact with a number type',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return last schema based on semver', async () => {
|
||||
await testDbClient.batchInsert('fact_schemas', additionalFactSchemas);
|
||||
|
||||
const schemas = await store.getLatestSchemas();
|
||||
expect(schemas[0]).toMatchObject({
|
||||
ref: 'test-schema',
|
||||
version: '1.2.1-test',
|
||||
schema: {
|
||||
testNumberFact: {
|
||||
type: 'integer',
|
||||
description: 'Test fact with a number type',
|
||||
entityKinds: ['component'],
|
||||
},
|
||||
testStringFact: {
|
||||
type: 'string',
|
||||
description: 'Test fact with a string type',
|
||||
entityKinds: ['service'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return latest facts only for the correct ref', async () => {
|
||||
const returnedFact = await store.getLatestFactsForRefs(
|
||||
['test-fact'],
|
||||
'a/a/a',
|
||||
);
|
||||
expect(returnedFact['test-fact']).toMatchObject(baseAssertionFact);
|
||||
});
|
||||
|
||||
it('should return latest facts for multiple refs', async () => {
|
||||
await testDbClient.batchInsert(
|
||||
'facts',
|
||||
additionalFacts.map(fact => ({
|
||||
...fact,
|
||||
ref: 'second-test-fact',
|
||||
timestamp: farInTheFuture,
|
||||
})),
|
||||
);
|
||||
const returnedFacts = await store.getLatestFactsForRefs(
|
||||
['test-fact', 'second-test-fact'],
|
||||
'a/a/a',
|
||||
);
|
||||
|
||||
expect(returnedFacts['test-fact']).toMatchObject({
|
||||
...baseAssertionFact,
|
||||
});
|
||||
expect(returnedFacts['second-test-fact']).toMatchObject({
|
||||
...baseAssertionFact,
|
||||
ref: 'second-test-fact',
|
||||
timestamp: DateTime.fromISO(farInTheFuture),
|
||||
facts: { testNumberFact: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return facts correctly between time range', async () => {
|
||||
await testDbClient.batchInsert('facts', additionalFacts);
|
||||
const returnedFacts = await store.getFactsBetweenTimestampsForRefs(
|
||||
['test-fact'],
|
||||
'a/a/a',
|
||||
DateTime.fromISO(now),
|
||||
DateTime.fromISO(shortlyInTheFuture).plus(Duration.fromMillis(10)),
|
||||
);
|
||||
expect(returnedFacts['test-fact']).toHaveLength(2);
|
||||
|
||||
expect(returnedFacts['test-fact'][0]).toMatchObject({
|
||||
...baseAssertionFact,
|
||||
timestamp: DateTime.fromISO(now),
|
||||
facts: { testNumberFact: 1 },
|
||||
});
|
||||
expect(returnedFacts['test-fact'][1]).toMatchObject({
|
||||
...baseAssertionFact,
|
||||
});
|
||||
expect(returnedFacts['test-fact']).not.toContainEqual({
|
||||
...baseAssertionFact,
|
||||
timestamp: DateTime.fromISO(farInTheFuture),
|
||||
facts: { testNumberFact: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Knex } from 'knex';
|
||||
import {
|
||||
FactSchema,
|
||||
TechInsightFact,
|
||||
FlatTechInsightFact,
|
||||
TechInsightsStore,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import { rsort } from 'semver';
|
||||
import { groupBy } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
export type RawDbFactRow = {
|
||||
ref: string;
|
||||
version: string;
|
||||
timestamp: Date | string;
|
||||
entity: string;
|
||||
facts: string;
|
||||
};
|
||||
|
||||
type RawDbFactSchemaRow = {
|
||||
id: number;
|
||||
ref: string;
|
||||
version: string;
|
||||
schema: string;
|
||||
};
|
||||
|
||||
export class TechInsightsDatabase implements TechInsightsStore {
|
||||
private readonly CHUNK_SIZE = 50;
|
||||
|
||||
constructor(private readonly db: Knex, private readonly logger: Logger) {}
|
||||
|
||||
async getLatestSchemas(refs?: string[]): Promise<FactSchema[]> {
|
||||
const queryBuilder = this.db<RawDbFactSchemaRow>('fact_schemas');
|
||||
if (refs) {
|
||||
queryBuilder.whereIn('ref', refs);
|
||||
}
|
||||
const existingSchemas = await queryBuilder.orderBy('id', 'desc').select();
|
||||
|
||||
const groupedSchemas = groupBy(existingSchemas, 'ref');
|
||||
return Object.values(groupedSchemas)
|
||||
.map(schemas => {
|
||||
const sorted = rsort(schemas.map(it => it.version));
|
||||
return schemas.find(it => it.version === sorted[0])!!;
|
||||
})
|
||||
.map((it: RawDbFactSchemaRow) => ({
|
||||
...it,
|
||||
schema: JSON.parse(it.schema),
|
||||
}));
|
||||
}
|
||||
|
||||
async insertFactSchema(ref: string, schema: FactSchema) {
|
||||
const existingSchemas = await this.db<RawDbFactSchemaRow>('fact_schemas')
|
||||
.where({ ref })
|
||||
.select();
|
||||
const exists = existingSchemas.some(
|
||||
it => it.ref === ref && it.version === schema.version,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
await this.db<RawDbFactSchemaRow>('fact_schemas').insert({
|
||||
ref,
|
||||
version: schema.version,
|
||||
schema: JSON.stringify(schema.schema),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async insertFacts(ref: string, facts: TechInsightFact[]): Promise<void> {
|
||||
if (facts.length === 0) return;
|
||||
const currentSchema = await this.getLatestSchema(ref);
|
||||
const factRows = facts.map(it => {
|
||||
const { namespace, name, kind } = it.entity;
|
||||
return {
|
||||
ref: ref,
|
||||
version: currentSchema.version,
|
||||
entity: `${namespace}/${kind}/${name}`.toLocaleLowerCase('en-US'),
|
||||
facts: JSON.stringify(it.facts),
|
||||
...(it.timestamp && { timestamp: it.timestamp.toJSDate() }),
|
||||
};
|
||||
});
|
||||
await this.db.transaction(async tx => {
|
||||
await tx.batchInsert<RawDbFactRow>('facts', factRows, this.CHUNK_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
async getLatestFactsForRefs(
|
||||
refs: string[],
|
||||
entityTriplet: string,
|
||||
): Promise<{ [p: string]: FlatTechInsightFact }> {
|
||||
const results = await this.db<RawDbFactRow>('facts')
|
||||
.where({ entity: entityTriplet })
|
||||
.and.whereIn('ref', refs)
|
||||
.join(
|
||||
this.db('facts')
|
||||
.max('timestamp')
|
||||
.column('ref as subRef')
|
||||
.groupBy('ref')
|
||||
.as('subQ'),
|
||||
'facts.ref',
|
||||
'subQ.subRef',
|
||||
);
|
||||
return this.dbFactRowsToTechInsightFacts(results);
|
||||
}
|
||||
|
||||
async getFactsBetweenTimestampsForRefs(
|
||||
refs: string[],
|
||||
entityTriplet: string,
|
||||
startDateTime: DateTime,
|
||||
endDateTime: DateTime,
|
||||
): Promise<{
|
||||
[p: string]: FlatTechInsightFact[];
|
||||
}> {
|
||||
const results = await this.db<RawDbFactRow>('facts')
|
||||
.where({ entity: entityTriplet })
|
||||
.and.whereIn('ref', refs)
|
||||
.and.whereBetween('timestamp', [
|
||||
startDateTime.toISO(),
|
||||
endDateTime.toISO(),
|
||||
]);
|
||||
|
||||
return groupBy(
|
||||
results.map(it => {
|
||||
const [namespace, kind, name] = it.entity.split('/');
|
||||
const timestamp =
|
||||
typeof it.timestamp === 'string'
|
||||
? DateTime.fromISO(it.timestamp)
|
||||
: DateTime.fromJSDate(it.timestamp);
|
||||
return {
|
||||
ref: it.ref,
|
||||
entity: { namespace, kind, name },
|
||||
timestamp,
|
||||
version: it.version,
|
||||
facts: JSON.parse(it.facts),
|
||||
};
|
||||
}),
|
||||
'ref',
|
||||
);
|
||||
}
|
||||
|
||||
private async getLatestSchema(ref: string): Promise<RawDbFactSchemaRow> {
|
||||
const existingSchemas = await this.db<RawDbFactSchemaRow>('fact_schemas')
|
||||
.where({ ref })
|
||||
.orderBy('id', 'desc')
|
||||
.select();
|
||||
if (existingSchemas.length < 1) {
|
||||
this.logger.warn(`No schema found for ${ref}. `);
|
||||
throw new Error(`No schema found for ${ref}. `);
|
||||
}
|
||||
const sorted = rsort(existingSchemas.map(it => it.version));
|
||||
return existingSchemas.find(it => it.version === sorted[0])!!;
|
||||
}
|
||||
|
||||
private dbFactRowsToTechInsightFacts(rows: RawDbFactRow[]) {
|
||||
return rows.reduce((acc, it) => {
|
||||
const [namespace, kind, name] = it.entity.split('/');
|
||||
const timestamp =
|
||||
typeof it.timestamp === 'string'
|
||||
? DateTime.fromISO(it.timestamp)
|
||||
: DateTime.fromJSDate(it.timestamp);
|
||||
return {
|
||||
...acc,
|
||||
[it.ref]: {
|
||||
ref: it.ref,
|
||||
entity: { namespace, kind, name },
|
||||
timestamp,
|
||||
version: it.version,
|
||||
facts: JSON.parse(it.facts),
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2021 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 { DefaultTechInsightsBuilder } from './DefaultTechInsightsBuilder';
|
||||
import { createRouter } from './router';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { PersistenceContext } from './persistence/DatabaseManager';
|
||||
import { TechInsightsStore } from '@backstage/plugin-tech-insights-common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
describe('Tech Insights router tests', () => {
|
||||
let app: express.Express;
|
||||
|
||||
const latestFactsForRefsMock = jest.fn();
|
||||
const factsBetweenTimestampsForRefsMock = jest.fn();
|
||||
const latestSchemasMock = jest.fn();
|
||||
|
||||
const mockPersistenceContext: PersistenceContext = {
|
||||
techInsightsStore: {
|
||||
getLatestFactsForRefs: latestFactsForRefsMock,
|
||||
getFactsBetweenTimestampsForRefs: factsBetweenTimestampsForRefsMock,
|
||||
getLatestSchemas: latestSchemasMock,
|
||||
} as unknown as TechInsightsStore,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const techInsightsContext = await new DefaultTechInsightsBuilder({
|
||||
database: {
|
||||
getClient: () => {
|
||||
return Promise.resolve({
|
||||
migrate: {
|
||||
latest: () => {},
|
||||
},
|
||||
}) as unknown as Promise<Knex>;
|
||||
},
|
||||
},
|
||||
logger: getVoidLogger(),
|
||||
factRetrievers: [],
|
||||
config: ConfigReader.fromConfigs([]),
|
||||
discovery: {
|
||||
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
|
||||
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
|
||||
},
|
||||
}).build();
|
||||
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
config: ConfigReader.fromConfigs([]),
|
||||
...techInsightsContext,
|
||||
persistenceContext: mockPersistenceContext,
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
it('should be able to retrieve latest schemas', async () => {
|
||||
await request(app).get('/fact-schemas').expect(200);
|
||||
expect(latestSchemasMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not contain check endpoints when checker not present', async () => {
|
||||
await request(app).get('/checks').expect(404);
|
||||
await request(app).get('/checks/a/a/a').expect(404);
|
||||
});
|
||||
|
||||
it('should parse be able to parse ref request params for fact retrieval', async () => {
|
||||
await request(app)
|
||||
.get('/facts/latest/a/a/a')
|
||||
.query({ refs: ['firstref', 'secondref'] })
|
||||
.expect(200);
|
||||
expect(latestFactsForRefsMock).toHaveBeenCalledWith(
|
||||
['firstref', 'secondref'],
|
||||
'a/a/a',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse be able to parse datetime request params for fact retrieval', async () => {
|
||||
await request(app)
|
||||
.get('/facts/range/a/a/a')
|
||||
.query({
|
||||
refs: ['firstref', 'secondref'],
|
||||
startDatetime: '2021-12-12T12:12:12',
|
||||
endDatetime: '2022-11-11T11:11:11',
|
||||
})
|
||||
.expect(200);
|
||||
expect(factsBetweenTimestampsForRefsMock).toHaveBeenCalledWith(
|
||||
['firstref', 'secondref'],
|
||||
'a/a/a',
|
||||
DateTime.fromISO('2021-12-12T12:12:12.000+00:00'),
|
||||
DateTime.fromISO('2022-11-11T11:11:11.000+00:00'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond gracefully on parsing errors', async () => {
|
||||
await request(app)
|
||||
.get('/facts/range/a/a/a')
|
||||
.query({
|
||||
refs: ['firstref', 'secondref'],
|
||||
startDatetime: '2021-12-1222T12:12:12',
|
||||
endDatetime: '2022-1122-11T11:11:11',
|
||||
})
|
||||
.expect(422);
|
||||
expect(latestFactsForRefsMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2021 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 express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
FactChecker,
|
||||
TechInsightCheck,
|
||||
CheckResult,
|
||||
} from '@backstage/plugin-tech-insights-common';
|
||||
import { Logger } from 'winston';
|
||||
import { DateTime } from 'luxon';
|
||||
import { PersistenceContext } from './persistence/DatabaseManager';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* RouterOptions to construct TechInsights endpoints
|
||||
* @typeParam CheckType - Type of the check for the fact checker this builder returns
|
||||
* @typeParam CheckResultType - Type of the check result for the fact checker this builder returns
|
||||
*/
|
||||
export interface RouterOptions<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
> {
|
||||
/**
|
||||
* Optional FactChecker implementation. If omitted, endpoints are not constructed
|
||||
*/
|
||||
factChecker?: FactChecker<CheckType, CheckResultType>;
|
||||
|
||||
/**
|
||||
* TechInsights PersistenceContext. Should contain an implementation of TechInsightsStore
|
||||
*/
|
||||
persistenceContext: PersistenceContext;
|
||||
|
||||
/**
|
||||
* Backstage config object
|
||||
*/
|
||||
config: Config;
|
||||
|
||||
/**
|
||||
* Implementation of Winston logger
|
||||
*/
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* Constructs a tech-insights router.
|
||||
*
|
||||
* Exposes endpoints to handle facts
|
||||
* Exposes optional endpoints to handle checks if a FactChecker implementation is passed in
|
||||
*
|
||||
* @param options - RouterOptions object
|
||||
*/
|
||||
export async function createRouter<
|
||||
CheckType extends TechInsightCheck,
|
||||
CheckResultType extends CheckResult,
|
||||
>(options: RouterOptions<CheckType, CheckResultType>): Promise<express.Router> {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
const { persistenceContext, factChecker, logger } = options;
|
||||
const { techInsightsStore } = persistenceContext;
|
||||
|
||||
if (factChecker) {
|
||||
logger.info('Fact checker configured. Enabling fact checking endpoints.');
|
||||
router.get('/checks', async (_req, res) => {
|
||||
return res.send(await factChecker.getChecks());
|
||||
});
|
||||
|
||||
router.get('/checks/:namespace/:kind/:name', async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const checks = req.query.checks as string[];
|
||||
const entityTriplet = `${namespace.toLowerCase()}/${kind.toLowerCase()}/${name.toLowerCase()}`;
|
||||
try {
|
||||
const checkResult = await factChecker.runChecks(entityTriplet, checks);
|
||||
return res.send(checkResult);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: e.message }).send();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Starting tech insights module without fact checking endpoints.',
|
||||
);
|
||||
}
|
||||
|
||||
router.get('/fact-schemas', async (req, res) => {
|
||||
const refs = req.query.refs as string[];
|
||||
return res.send(await techInsightsStore.getLatestSchemas(refs));
|
||||
});
|
||||
|
||||
router.get('/facts/latest/:namespace/:kind/:name', async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const refs = req.query.refs as string[];
|
||||
const entityTriplet = `${namespace.toLowerCase()}/${kind.toLowerCase()}/${name.toLowerCase()}`;
|
||||
return res.send(
|
||||
await techInsightsStore.getLatestFactsForRefs(refs, entityTriplet),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/facts/range/:namespace/:kind/:name', async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const refs = req.query.refs as string[];
|
||||
const startDatetime = DateTime.fromISO(req.query.startDatetime as string);
|
||||
const endDatetime = DateTime.fromISO(req.query.endDatetime as string);
|
||||
if (!startDatetime.isValid || !endDatetime.isValid) {
|
||||
return res.status(422).send('Failed to parse datetime from request');
|
||||
}
|
||||
const entityTriplet = `${namespace.toLowerCase()}/${kind.toLowerCase()}/${name.toLowerCase()}`;
|
||||
return res.send(
|
||||
await techInsightsStore.getFactsBetweenTimestampsForRefs(
|
||||
refs,
|
||||
entityTriplet,
|
||||
startDatetime,
|
||||
endDatetime,
|
||||
),
|
||||
);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user