Split fact retrievers

Split fact retriever into severeal more specialised retrievers.
Ditch the dynamic addition of annotation facts in favour of
explicit annotation facts. Having dynamic facts makes the
versioning system somewhat meaningless.

Signed-off-by: Iain Billett <iain@roadie.io>
This commit is contained in:
Iain Billett
2021-11-17 15:21:18 +00:00
parent 8cf89aab38
commit ccc8a7ff5d
10 changed files with 542 additions and 150 deletions
+29 -6
View File
@@ -17,7 +17,9 @@ import {
createRouter,
buildTechInsightsContext,
createFactRetrieverRegistration,
createCatalogFactRetriever,
entityOwnershipFactRetriever,
entityMetadataFactRetriever,
techdocsFactRetriever,
} from '@backstage/plugin-tech-insights-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -40,8 +42,10 @@ export default async function createPlugin({
factRetrievers: [
createFactRetrieverRegistration(
'* * * * *', // Example cron, every minute
createCatalogFactRetriever(),
entityOwnershipFactRetriever,
),
createFactRetrieverRegistration('* * * * *', entityMetadataFactRetriever),
createFactRetrieverRegistration('* * * * *', techdocsFactRetriever),
],
factCheckerFactory: new JsonRulesEngineFactCheckerFactory({
checks: [
@@ -50,14 +54,33 @@ export default async function createPlugin({
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'simpleTestCheck',
description: 'Simple Check For Testing',
factIds: ['testRetriever'],
factIds: [
'entityMetadataFactRetriever',
'techdocsFactRetriever',
'entityOwnershipFactRetriever',
],
rule: {
conditions: {
all: [
{
fact: 'examplenumberfact',
operator: 'lessThan',
value: 5,
fact: 'hasGroupOwner',
operator: 'equal',
value: true,
},
{
fact: 'hasTitle',
operator: 'equal',
value: true,
},
{
fact: 'hasDescription',
operator: 'equal',
value: true,
},
{
fact: 'hasAnnotationBackstageIoTechdocsRef',
operator: 'equal',
value: true,
},
],
},
+9 -8
View File
@@ -24,14 +24,6 @@ export const buildTechInsightsContext: <
options: TechInsightsOptions<CheckType, CheckResultType>,
) => Promise<TechInsightsContext<CheckType, CheckResultType>>;
// Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createCatalogFactRetriever" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createCatalogFactRetriever: ({
annotations,
}?: Options) => FactRetriever;
// @public
export function createFactRetrieverRegistration(
cadence: string,
@@ -44,6 +36,12 @@ export function createRouter<
CheckResultType extends CheckResult,
>(options: RouterOptions<CheckType, CheckResultType>): Promise<express.Router>;
// @public
export const entityMetadataFactRetriever: FactRetriever;
// @public
export const entityOwnershipFactRetriever: FactRetriever;
// @public
export type PersistenceContext = {
techInsightsStore: TechInsightsStore;
@@ -60,6 +58,9 @@ export interface RouterOptions<
persistenceContext: PersistenceContext;
}
// @public
export const techdocsFactRetriever: FactRetriever;
// @public (undocumented)
export type TechInsightsContext<
CheckType extends TechInsightCheck,
@@ -0,0 +1,181 @@
/*
* 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 { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CatalogListResponse } from '@backstage/catalog-client';
import { entityMetadataFactRetriever } from './entityMetadataFactRetriever';
const getEntitiesMock = jest.fn();
jest.mock('@backstage/catalog-client', () => {
return {
CatalogClient: jest
.fn()
.mockImplementation(() => ({ getEntities: getEntitiesMock })),
};
});
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const defaultEntityListResponse: CatalogListResponse<Entity> = {
items: [
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-owner',
description: 'service with an owner',
tags: ['a-tag'],
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'team-a',
},
relations: [
{
type: RELATION_OWNED_BY,
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-incomplete-data',
description: '',
tags: [],
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: '',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-user-owner',
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'user:my-user',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'user-a',
},
kind: 'User',
spec: {
memberOf: 'group-a',
},
},
],
};
const handlerContext = {
discovery,
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
};
const entityFactRetriever = entityMetadataFactRetriever;
describe('entityMetadataFactRetriever', () => {
beforeEach(() => {
getEntitiesMock.mockResolvedValue(defaultEntityListResponse);
});
afterEach(() => {
getEntitiesMock.mockClear();
});
describe('hasDescription', () => {
describe('where the entity has a description', () => {
it('returns true for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasDescription: true,
},
});
});
});
describe('where the entity does not have a description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
describe('where the entity has an empty value for description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
});
describe('hasTags', () => {
describe('where the entity has tags', () => {
it('returns true for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasTags: true,
},
});
});
});
describe('where the entity has no tags', () => {
it('returns false for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasTags: false,
},
});
});
});
});
});
@@ -0,0 +1,68 @@
/*
* 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,
} from '@backstage/plugin-tech-insights-node';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import isEmpty from 'lodash/isEmpty';
/**
* Generates facts which indicate the completeness of entity metadata.
*
* @public
*/
export const entityMetadataFactRetriever: FactRetriever = {
id: 'entityMetadataFactRetriever',
version: '0.0.1',
schema: {
hasTitle: {
type: 'boolean',
description: 'The entity has a title in metadata',
},
hasDescription: {
type: 'boolean',
description: 'The entity has a description in metadata',
},
hasTags: {
type: 'boolean',
description: 'The entity has tags in metadata',
},
},
handler: async ({ discovery }: FactRetrieverContext) => {
const catalogClient = new CatalogClient({
discoveryApi: discovery,
});
const entities = await catalogClient.getEntities();
return entities.items.map((entity: Entity) => {
return {
entity: {
namespace: entity.metadata.namespace!!,
kind: entity.kind,
name: entity.metadata.name,
},
facts: {
hasTitle: Boolean(entity.metadata?.title),
hasDescription: Boolean(entity.metadata?.description),
hasTags: !isEmpty(entity.metadata?.tags),
},
};
});
},
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createCatalogFactRetriever } from './catalogFactRetriever';
import { entityOwnershipFactRetriever } from './entityOwnershipFactRetriever';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
PluginEndpointDiscovery,
@@ -106,9 +106,7 @@ const handlerContext = {
config: ConfigReader.fromConfigs([]),
};
const entityFactRetriever = createCatalogFactRetriever({
annotations: ['backstage.io/techdocs-ref'],
});
const entityFactRetriever = entityOwnershipFactRetriever;
describe('entityFactRetriever', () => {
beforeEach(() => {
@@ -193,95 +191,4 @@ describe('entityFactRetriever', () => {
});
});
});
describe('hasDescription', () => {
describe('where the entity has a description', () => {
it('returns true for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasDescription: true,
},
});
});
});
describe('where the entity does not have a description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
describe('where the entity has an empty value for description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
});
describe('hasTags', () => {
describe('where the entity has tags', () => {
it('returns true for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasTags: true,
},
});
});
});
describe('where the entity has no tags', () => {
it('returns false for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasTags: false,
},
});
});
});
});
describe('dynamic annotation facts', () => {
describe('where the retriever is configured to check for the techdocs annotation', () => {
describe('where the entity has the techdocs annotation', () => {
it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: true,
},
});
});
});
describe('where the entity is missing the techdocs annotation', () => {
it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: false,
},
});
});
});
});
});
});
@@ -20,19 +20,21 @@ import {
} from '@backstage/plugin-tech-insights-node';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import isEmpty from 'lodash/isEmpty';
import camelCase from 'lodash/camelCase';
import { get } from 'lodash';
type Options = {
annotations?: string[];
};
export const createCatalogFactRetriever = (
{ annotations = [] }: Options = { annotations: [] },
): FactRetriever => ({
id: 'entityRetriever',
/**
* Generates facts which indicate the quality of data in the spec.owner field.
*
* @public
*/
export const entityOwnershipFactRetriever: FactRetriever = {
id: 'entityOwnershipFactRetriever',
version: '0.0.1',
entityFilter: [
{
field: 'kind',
values: ['component', 'domain', 'system', 'api', 'resource', 'template'],
},
],
schema: {
hasOwner: {
type: 'boolean',
@@ -42,23 +44,6 @@ export const createCatalogFactRetriever = (
type: 'boolean',
description: 'The spec.owner field is set and refers to a group',
},
hasDescription: {
type: 'boolean',
description: 'The entity has a description in metadata',
},
hasTags: {
type: 'boolean',
description: 'The entity has tags in metadata',
},
...annotations.reduce((acc: object, annotation: string) => {
return {
...acc,
[camelCase(`hasAnnotation-${annotation}`)]: {
type: 'boolean',
description: `The entity has the annotation: ${annotation} `,
},
};
}, {}),
},
handler: async ({ discovery }: FactRetrieverContext) => {
const catalogClient = new CatalogClient({
@@ -79,19 +64,8 @@ export const createCatalogFactRetriever = (
entity.spec?.owner &&
!(entity.spec?.owner as string).startsWith('user:'),
),
hasDescription: Boolean(entity.metadata?.description),
hasTags: !isEmpty(entity.metadata?.tags),
...annotations.reduce(
(acc: object, annotation: string) => ({
...acc,
[camelCase(`hasAnnotation-${annotation}`)]: Boolean(
get(entity, ['metadata', 'annotations', annotation]),
),
}),
{},
),
},
};
});
},
});
};
@@ -13,4 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './catalogFactRetriever';
export * from './entityOwnershipFactRetriever';
export * from './entityMetadataFactRetriever';
export * from './techdocsFactRetriever';
@@ -0,0 +1,147 @@
/*
* 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 { techdocsFactRetriever } from './techdocsFactRetriever';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CatalogListResponse } from '@backstage/catalog-client';
const getEntitiesMock = jest.fn();
jest.mock('@backstage/catalog-client', () => {
return {
CatalogClient: jest
.fn()
.mockImplementation(() => ({ getEntities: getEntitiesMock })),
};
});
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const defaultEntityListResponse: CatalogListResponse<Entity> = {
items: [
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-owner',
description: 'service with an owner',
tags: ['a-tag'],
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'team-a',
},
relations: [
{
type: RELATION_OWNED_BY,
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-incomplete-data',
description: '',
tags: [],
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: '',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-user-owner',
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'user:my-user',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'user-a',
},
kind: 'User',
spec: {
memberOf: 'group-a',
},
},
],
};
const handlerContext = {
discovery,
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
};
const entityFactRetriever = techdocsFactRetriever;
describe('techdocsFactRetriever', () => {
beforeEach(() => {
getEntitiesMock.mockResolvedValue(defaultEntityListResponse);
});
afterEach(() => {
getEntitiesMock.mockClear();
});
describe('hasAnnotationBackstageIoTechdocsRef', () => {
describe('where the retriever is configured to check for the techdocs annotation', () => {
describe('where the entity has the techdocs annotation', () => {
it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: true,
},
});
});
});
describe('where the entity is missing the techdocs annotation', () => {
it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: false,
},
});
});
});
});
});
});
@@ -0,0 +1,65 @@
/*
* 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,
} from '@backstage/plugin-tech-insights-node';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { entityHasAnnotation, generateAnnotationFactName } from './utils';
const techdocsAnnotation = 'backstage.io/techdocs-ref';
const techdocsAnnotationFactName =
generateAnnotationFactName(techdocsAnnotation);
/**
* Generates facts related to the completeness of techdocs configuration for entities.
*
* @public
*/
export const techdocsFactRetriever: FactRetriever = {
id: 'techdocsFactRetriever',
version: '0.0.1',
schema: {
[techdocsAnnotationFactName]: {
type: 'boolean',
description: 'The entity has a title in metadata',
},
},
handler: async ({ discovery }: FactRetrieverContext) => {
const catalogClient = new CatalogClient({
discoveryApi: discovery,
});
const entities = await catalogClient.getEntities();
return entities.items.map((entity: Entity) => {
return {
entity: {
namespace: entity.metadata.namespace!!,
kind: entity.kind,
name: entity.metadata.name,
},
facts: {
[techdocsAnnotationFactName]: entityHasAnnotation(
entity,
techdocsAnnotation,
),
},
};
});
},
};
@@ -0,0 +1,24 @@
/*
* 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 camelCase from 'lodash/camelCase';
import { Entity } from '@backstage/catalog-model';
import { get } from 'lodash';
export const generateAnnotationFactName = (annotation: string) =>
camelCase(`hasAnnotation-${annotation}`);
export const entityHasAnnotation = (entity: Entity, annotation: string) =>
Boolean(get(entity, ['metadata', 'annotations', annotation]));