From f091863f038b7606033f35216ef45834c75b956f Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Tue, 2 Nov 2021 17:47:09 +0000 Subject: [PATCH 01/12] Add an entity fact retriever Signed-off-by: Iain Billett --- .../entityFactRetriever.test.ts | 111 ++++++++++++++++++ .../factRetrievers/entityFactRetriever.ts | 92 +++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts new file mode 100644 index 0000000000..c7c8ecf77a --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { entityFactRetriever } from './entityFactRetriever'; +import { Entity } 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 = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { name: 'service-with-owner' }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'group:team-a', + }, + relations: [ + { + type: 'ownedBy', + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-without-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +describe('entityFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasSpecWithOwner', () => { + describe('where the entity has an owner in the spec', () => { + it('returns true for hasSpecWithOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasSpecWithOwner: true, + }, + }); + }); + }); + describe('where the entity does not have an owner in the spec', () => { + it('returns false for hasSpecWithOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-without-owner'), + ).toMatchObject({ + facts: { + hasSpecWithOwner: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts new file mode 100644 index 0000000000..8bc670b873 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts @@ -0,0 +1,92 @@ +/* + * 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 { FactRetrieverContext } from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { RELATION_OWNED_BY } from '@backstage/catalog-model'; + +const applicableEntityKinds = [ + 'component', + 'api', + 'template', + 'domain', + 'resource', + 'system', +]; + +const entityKindFilter = { field: 'kind', values: applicableEntityKinds }; + +export const entityFactRetriever = { + id: 'entityRetriever', + version: '0.0.1', + entityFilter: [ + { + field: 'kind', + values: applicableEntityKinds, + }, + ], + schema: { + hasSpecWithOwner: { + type: 'boolean', + description: 'The spec.owner field is set', + }, + hasSpecWithGroupOwner: { + type: 'boolean', + description: 'The spec.owner field is set and refers to a group', + }, + hasOwnedByRelation: { + type: 'boolean', + description: 'The entity has an owned_by relation', + }, + hasOwnedByRelationWithGroupTarget: { + type: 'boolean', + description: 'The entity has an owned_by relation which targets a group', + }, + }, + handler: async ({ discovery }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ + filter: entityKindFilter, + }); + + return entities.items.map(it => { + return { + entity: { + namespace: it.metadata.namespace!!, + kind: it.kind, + name: it.metadata.name, + }, + facts: { + hasSpecWithOwner: Boolean(it.spec?.owner), + hasSpecWithGroupOwner: Boolean(it.spec?.owner && it.spec?.owner), + hasOwnedByRelation: Boolean( + it.relations && + it.relations.find(({ type }) => type === RELATION_OWNED_BY), + ), + hasOwnedByRelationWithGroupTarget: Boolean( + it.relations && + it.relations.find( + ({ type, target }) => + type === RELATION_OWNED_BY && target.kind === 'group', + ), + ), + }, + }; + }); + }, +}; From 23195a4494fdb37eebf2796ea16f1ddb330227cb Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Tue, 2 Nov 2021 18:26:36 +0000 Subject: [PATCH 02/12] Add some more test cases Signed-off-by: Iain Billett --- .../entityFactRetriever.test.ts | 55 ++++++++++++++++++- .../factRetrievers/entityFactRetriever.ts | 8 ++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts index c7c8ecf77a..fe4cd18645 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts @@ -15,7 +15,7 @@ */ import { entityFactRetriever } from './entityFactRetriever'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { PluginEndpointDiscovery, getVoidLogger, @@ -49,7 +49,7 @@ const defaultEntityListResponse: CatalogListResponse = { }, relations: [ { - type: 'ownedBy', + type: RELATION_OWNED_BY, target: { name: 'team-a', kind: 'group', namespace: 'default' }, }, ], @@ -63,6 +63,19 @@ const defaultEntityListResponse: CatalogListResponse = { 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', }, }, ], @@ -108,4 +121,42 @@ describe('entityFactRetriever', () => { }); }); }); + describe('hasSpecWithGroupOwner', () => { + describe('where the entity has an group as owner in the spec', () => { + it('returns true for hasSpecWithGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasSpecWithGroupOwner: true, + }, + }); + }); + }); + describe('where the entity has a user as owner in the spec', () => { + it('returns false for hasSpecWithGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-user-owner'), + ).toMatchObject({ + facts: { + hasSpecWithGroupOwner: false, + }, + }); + }); + }); + describe('where the entity does not have an owner in the spec', () => { + it('returns false for hasSpecWithGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-without-owner'), + ).toMatchObject({ + facts: { + hasSpecWithGroupOwner: false, + }, + }); + }); + }); + }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts index 8bc670b873..1488cf71ca 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts @@ -16,7 +16,7 @@ import { FactRetrieverContext } from '@backstage/plugin-tech-insights-node'; import { CatalogClient } from '@backstage/catalog-client'; -import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; const applicableEntityKinds = [ 'component', @@ -64,7 +64,7 @@ export const entityFactRetriever = { filter: entityKindFilter, }); - return entities.items.map(it => { + return entities.items.map((it: Entity) => { return { entity: { namespace: it.metadata.namespace!!, @@ -73,7 +73,9 @@ export const entityFactRetriever = { }, facts: { hasSpecWithOwner: Boolean(it.spec?.owner), - hasSpecWithGroupOwner: Boolean(it.spec?.owner && it.spec?.owner), + hasSpecWithGroupOwner: Boolean( + it.spec?.owner && (it.spec?.owner as string).startsWith('group:'), + ), hasOwnedByRelation: Boolean( it.relations && it.relations.find(({ type }) => type === RELATION_OWNED_BY), From 9d2d1bd00cedafc52db4c90dbf72c36f9d2aca5e Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 8 Nov 2021 12:42:21 +0000 Subject: [PATCH 03/12] Add some more test cases Signed-off-by: Iain Billett --- .../entityFactRetriever.test.ts | 125 +++++++++++++++--- .../factRetrievers/entityFactRetriever.ts | 51 ++----- 2 files changed, 117 insertions(+), 59 deletions(-) diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts index fe4cd18645..62769288fd 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts @@ -40,7 +40,11 @@ const defaultEntityListResponse: CatalogListResponse = { items: [ { apiVersion: 'backstage.io/v1beta1', - metadata: { name: 'service-with-owner' }, + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + }, kind: 'Component', spec: { type: 'service', @@ -57,7 +61,9 @@ const defaultEntityListResponse: CatalogListResponse = { { apiVersion: 'backstage.io/v1beta1', metadata: { - name: 'service-without-owner', + name: 'service-with-incomplete-data', + description: '', + tags: [], }, kind: 'Component', spec: { @@ -78,6 +84,16 @@ const defaultEntityListResponse: CatalogListResponse = { owner: 'user:my-user', }, }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, ], }; @@ -95,65 +111,138 @@ describe('entityFactRetriever', () => { getEntitiesMock.mockClear(); }); - describe('hasSpecWithOwner', () => { + describe('hasOwner', () => { describe('where the entity has an owner in the spec', () => { - it('returns true for hasSpecWithOwner', async () => { + it('returns true for hasOwner', async () => { const facts = await entityFactRetriever.handler(handlerContext); expect( facts.find(it => it.entity.name === 'service-with-owner'), ).toMatchObject({ facts: { - hasSpecWithOwner: true, + hasOwner: true, }, }); }); }); - describe('where the entity does not have an owner in the spec', () => { - it('returns false for hasSpecWithOwner', async () => { + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasOwner', async () => { const facts = await entityFactRetriever.handler(handlerContext); expect( - facts.find(it => it.entity.name === 'service-without-owner'), + facts.find(it => it.entity.name === 'service-with-incomplete-data'), ).toMatchObject({ facts: { - hasSpecWithOwner: false, + hasOwner: false, + }, + }); + }); + }); + + describe('where the entity doesn not have an owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasOwner: false, }, }); }); }); }); - describe('hasSpecWithGroupOwner', () => { + describe('hasGroupOwner', () => { describe('where the entity has an group as owner in the spec', () => { - it('returns true for hasSpecWithGroupOwner', async () => { + it('returns true for hasGroupOwner', async () => { const facts = await entityFactRetriever.handler(handlerContext); expect( facts.find(it => it.entity.name === 'service-with-owner'), ).toMatchObject({ facts: { - hasSpecWithGroupOwner: true, + hasGroupOwner: true, }, }); }); }); describe('where the entity has a user as owner in the spec', () => { - it('returns false for hasSpecWithGroupOwner', async () => { + it('returns false for hasGroupOwner', async () => { const facts = await entityFactRetriever.handler(handlerContext); expect( facts.find(it => it.entity.name === 'service-with-user-owner'), ).toMatchObject({ facts: { - hasSpecWithGroupOwner: false, + hasGroupOwner: false, }, }); }); }); - describe('where the entity does not have an owner in the spec', () => { - it('returns false for hasSpecWithGroupOwner', async () => { + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { const facts = await entityFactRetriever.handler(handlerContext); expect( - facts.find(it => it.entity.name === 'service-without-owner'), + facts.find(it => it.entity.name === 'service-with-incomplete-data'), ).toMatchObject({ facts: { - hasSpecWithGroupOwner: false, + hasGroupOwner: false, + }, + }); + }); + }); + }); + 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, }, }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts index 1488cf71ca..7d6a3987e0 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts @@ -16,53 +16,31 @@ import { FactRetrieverContext } from '@backstage/plugin-tech-insights-node'; import { CatalogClient } from '@backstage/catalog-client'; -import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; - -const applicableEntityKinds = [ - 'component', - 'api', - 'template', - 'domain', - 'resource', - 'system', -]; - -const entityKindFilter = { field: 'kind', values: applicableEntityKinds }; +import { Entity } from '@backstage/catalog-model'; +import isEmpty from 'lodash/isEmpty'; export const entityFactRetriever = { id: 'entityRetriever', version: '0.0.1', - entityFilter: [ - { - field: 'kind', - values: applicableEntityKinds, - }, - ], schema: { - hasSpecWithOwner: { + hasOwner: { type: 'boolean', description: 'The spec.owner field is set', }, - hasSpecWithGroupOwner: { + hasGroupOwner: { type: 'boolean', description: 'The spec.owner field is set and refers to a group', }, - hasOwnedByRelation: { + hasDescription: { type: 'boolean', description: 'The entity has an owned_by relation', }, - hasOwnedByRelationWithGroupTarget: { - type: 'boolean', - description: 'The entity has an owned_by relation which targets a group', - }, }, handler: async ({ discovery }: FactRetrieverContext) => { const catalogClient = new CatalogClient({ discoveryApi: discovery, }); - const entities = await catalogClient.getEntities({ - filter: entityKindFilter, - }); + const entities = await catalogClient.getEntities(); return entities.items.map((it: Entity) => { return { @@ -72,21 +50,12 @@ export const entityFactRetriever = { name: it.metadata.name, }, facts: { - hasSpecWithOwner: Boolean(it.spec?.owner), - hasSpecWithGroupOwner: Boolean( + hasOwner: Boolean(it.spec?.owner), + hasGroupOwner: Boolean( it.spec?.owner && (it.spec?.owner as string).startsWith('group:'), ), - hasOwnedByRelation: Boolean( - it.relations && - it.relations.find(({ type }) => type === RELATION_OWNED_BY), - ), - hasOwnedByRelationWithGroupTarget: Boolean( - it.relations && - it.relations.find( - ({ type, target }) => - type === RELATION_OWNED_BY && target.kind === 'group', - ), - ), + hasDescription: Boolean(it.metadata?.description), + hasTags: !isEmpty(it.metadata?.tags), }, }; }); From 2d4593254fcfe625001ad025bf9250aaa0572d16 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Fri, 12 Nov 2021 10:20:25 +0000 Subject: [PATCH 04/12] Dynamic annotations Signed-off-by: Iain Billett --- .../entityFactRetriever.test.ts | 38 +++++++++++++++- .../factRetrievers/entityFactRetriever.ts | 45 ++++++++++++++----- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts index 62769288fd..a1741c22ee 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { entityFactRetriever } from './entityFactRetriever'; +import { createEntityFactRetriever } from './entityFactRetriever'; import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { PluginEndpointDiscovery, @@ -44,6 +44,9 @@ const defaultEntityListResponse: CatalogListResponse = { name: 'service-with-owner', description: 'service with an owner', tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, }, kind: 'Component', spec: { @@ -103,6 +106,10 @@ const handlerContext = { config: ConfigReader.fromConfigs([]), }; +const entityFactRetriever = createEntityFactRetriever({ + annotations: ['backstage.io/techdocs-ref'], +}); + describe('entityFactRetriever', () => { beforeEach(() => { getEntitiesMock.mockResolvedValue(defaultEntityListResponse); @@ -248,4 +255,33 @@ describe('entityFactRetriever', () => { }); }); }); + + 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, + }, + }); + }); + }); + }); + }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts index 7d6a3987e0..66501ab1ec 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts @@ -18,8 +18,14 @@ import { FactRetrieverContext } 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'; -export const entityFactRetriever = { +export const createEntityFactRetriever = ({ + annotations = [], +}: { + annotations?: string[]; +}) => ({ id: 'entityRetriever', version: '0.0.1', schema: { @@ -35,6 +41,15 @@ export const entityFactRetriever = { type: 'boolean', description: 'The entity has an owned_by relation', }, + ...annotations.reduce((acc: object, it: string) => { + return { + ...acc, + [camelCase(`hasAnnotation-${it}`)]: { + type: 'boolean', + description: `The entity has the annotation: ${it} `, + }, + }; + }, {}), }, handler: async ({ discovery }: FactRetrieverContext) => { const catalogClient = new CatalogClient({ @@ -42,22 +57,32 @@ export const entityFactRetriever = { }); const entities = await catalogClient.getEntities(); - return entities.items.map((it: Entity) => { + return entities.items.map((entity: Entity) => { return { entity: { - namespace: it.metadata.namespace!!, - kind: it.kind, - name: it.metadata.name, + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, }, facts: { - hasOwner: Boolean(it.spec?.owner), + hasOwner: Boolean(entity.spec?.owner), hasGroupOwner: Boolean( - it.spec?.owner && (it.spec?.owner as string).startsWith('group:'), + entity.spec?.owner && + (entity.spec?.owner as string).startsWith('group:'), + ), + 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]), + ), + }), + {}, ), - hasDescription: Boolean(it.metadata?.description), - hasTags: !isEmpty(it.metadata?.tags), }, }; }); }, -}; +}); From 5b31874a768921ee5c4407957240fbf3333c8191 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 15 Nov 2021 12:20:01 +0000 Subject: [PATCH 05/12] Add hasTags schema Signed-off-by: Iain Billett --- .../src/service/fact/factRetrievers/entityFactRetriever.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts index 66501ab1ec..1442d4d929 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts @@ -41,6 +41,10 @@ export const createEntityFactRetriever = ({ type: 'boolean', description: 'The entity has an owned_by relation', }, + hasTags: { + type: 'boolean', + description: 'The entity has tags in metadata', + }, ...annotations.reduce((acc: object, it: string) => { return { ...acc, From d4a3dbb75b448137cda3578b7c90e99f4e3b2f89 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 15 Nov 2021 18:27:48 +0000 Subject: [PATCH 06/12] Expose catalogFactRetriever & configure in backend Signed-off-by: Iain Billett --- packages/backend/src/plugins/techInsights.ts | 41 +++---------------- plugins/tech-insights-backend/src/index.ts | 1 + ...r.test.ts => catalogFactRetriever.test.ts} | 6 +-- ...ctRetriever.ts => catalogFactRetriever.ts} | 25 ++++++----- .../src/service/fact/factRetrievers/index.ts | 16 ++++++++ 5 files changed, 40 insertions(+), 49 deletions(-) rename plugins/tech-insights-backend/src/service/fact/factRetrievers/{entityFactRetriever.test.ts => catalogFactRetriever.test.ts} (98%) rename plugins/tech-insights-backend/src/service/fact/factRetrievers/{entityFactRetriever.ts => catalogFactRetriever.ts} (81%) create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts diff --git a/packages/backend/src/plugins/techInsights.ts b/packages/backend/src/plugins/techInsights.ts index abab1d133d..6194f03410 100644 --- a/packages/backend/src/plugins/techInsights.ts +++ b/packages/backend/src/plugins/techInsights.ts @@ -17,10 +17,10 @@ import { createRouter, buildTechInsightsContext, createFactRetrieverRegistration, + createCatalogFactRetriever, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { CatalogClient } from '@backstage/catalog-client'; import { JsonRulesEngineFactCheckerFactory, JSON_RULE_ENGINE_CHECK_TYPE, @@ -38,41 +38,10 @@ export default async function createPlugin({ database, discovery, factRetrievers: [ - createFactRetrieverRegistration('5 4 * * 6', { - // Example cron, At 04:05 on Saturday. - id: 'testRetriever', - version: '1.1.2', - entityFilter: [{ kind: 'component' }], // EntityFilter to be used in the future (creating checks, graphs etc.) to figure out which entities this fact retrieves data for. - schema: { - examplenumberfact: { - type: 'integer', - description: 'Example fact returning a number', - }, - }, - handler: async _ctx => { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - const entities = await catalogClient.getEntities({ - filter: [{ kind: 'component' }], - }); - - return Promise.resolve( - entities.items.map(it => { - return { - entity: { - namespace: it.metadata.namespace!!, - kind: it.kind, - name: it.metadata.name, - }, - facts: { - examplenumberfact: 2, - }, - }; - }), - ); - }, - }), + createFactRetrieverRegistration( + '* * * * *', // Example cron, every minute + createCatalogFactRetriever(), + ), ], factCheckerFactory: new JsonRulesEngineFactCheckerFactory({ checks: [ diff --git a/plugins/tech-insights-backend/src/index.ts b/plugins/tech-insights-backend/src/index.ts index 5e8e690e8d..763c565108 100644 --- a/plugins/tech-insights-backend/src/index.ts +++ b/plugins/tech-insights-backend/src/index.ts @@ -25,3 +25,4 @@ export type { export type { PersistenceContext } from './service/persistence/persistenceContext'; export { createFactRetrieverRegistration } from './service/fact/createFactRetriever'; +export * from './service/fact/factRetrievers'; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts similarity index 98% rename from plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts rename to plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts index a1741c22ee..7b762be63f 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createEntityFactRetriever } from './entityFactRetriever'; +import { createCatalogFactRetriever } from './catalogFactRetriever'; import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { PluginEndpointDiscovery, @@ -52,7 +52,7 @@ const defaultEntityListResponse: CatalogListResponse = { spec: { type: 'service', lifecycle: 'test', - owner: 'group:team-a', + owner: 'team-a', }, relations: [ { @@ -106,7 +106,7 @@ const handlerContext = { config: ConfigReader.fromConfigs([]), }; -const entityFactRetriever = createEntityFactRetriever({ +const entityFactRetriever = createCatalogFactRetriever({ annotations: ['backstage.io/techdocs-ref'], }); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts similarity index 81% rename from plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts rename to plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts index 1442d4d929..d4d6e54865 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts @@ -14,18 +14,23 @@ * limitations under the License. */ -import { FactRetrieverContext } from '@backstage/plugin-tech-insights-node'; +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'; import camelCase from 'lodash/camelCase'; import { get } from 'lodash'; -export const createEntityFactRetriever = ({ - annotations = [], -}: { +type Options = { annotations?: string[]; -}) => ({ +}; + +export const createCatalogFactRetriever = ( + { annotations = [] }: Options = { annotations: [] }, +): FactRetriever => ({ id: 'entityRetriever', version: '0.0.1', schema: { @@ -39,18 +44,18 @@ export const createEntityFactRetriever = ({ }, hasDescription: { type: 'boolean', - description: 'The entity has an owned_by relation', + description: 'The entity has a description in metadata', }, hasTags: { type: 'boolean', description: 'The entity has tags in metadata', }, - ...annotations.reduce((acc: object, it: string) => { + ...annotations.reduce((acc: object, annotation: string) => { return { ...acc, - [camelCase(`hasAnnotation-${it}`)]: { + [camelCase(`hasAnnotation-${annotation}`)]: { type: 'boolean', - description: `The entity has the annotation: ${it} `, + description: `The entity has the annotation: ${annotation} `, }, }; }, {}), @@ -72,7 +77,7 @@ export const createEntityFactRetriever = ({ hasOwner: Boolean(entity.spec?.owner), hasGroupOwner: Boolean( entity.spec?.owner && - (entity.spec?.owner as string).startsWith('group:'), + !(entity.spec?.owner as string).startsWith('user:'), ), hasDescription: Boolean(entity.metadata?.description), hasTags: !isEmpty(entity.metadata?.tags), diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts new file mode 100644 index 0000000000..58ee8f8e92 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './catalogFactRetriever'; From 5c00e450458805513a4bfe035bf53e37d1bf21ea Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 15 Nov 2021 18:48:45 +0000 Subject: [PATCH 07/12] Changeset Signed-off-by: Iain Billett --- .changeset/ten-planes-remember.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/ten-planes-remember.md diff --git a/.changeset/ten-planes-remember.md b/.changeset/ten-planes-remember.md new file mode 100644 index 0000000000..2887aec568 --- /dev/null +++ b/.changeset/ten-planes-remember.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-tech-insights-backend': patch +--- + +Add a catalog fact retriever + +Add a fact retriever which generates facts related to the completeness +of entity data in the catalog. From 8cf89aab389fb5ffd6a5b1adc6dd01580b06e63c Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Mon, 15 Nov 2021 19:40:22 +0000 Subject: [PATCH 08/12] Api docs Signed-off-by: Iain Billett --- plugins/tech-insights-backend/api-report.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/tech-insights-backend/api-report.md b/plugins/tech-insights-backend/api-report.md index 826789dd1d..2ed5919484 100644 --- a/plugins/tech-insights-backend/api-report.md +++ b/plugins/tech-insights-backend/api-report.md @@ -24,6 +24,14 @@ export const buildTechInsightsContext: < options: TechInsightsOptions, ) => Promise>; +// 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, From ccc8a7ff5d8c4d0d06e5b032ac8c64c1e680a155 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 17 Nov 2021 15:21:18 +0000 Subject: [PATCH 09/12] 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 --- packages/backend/src/plugins/techInsights.ts | 35 +++- plugins/tech-insights-backend/api-report.md | 17 +- .../entityMetadataFactRetriever.test.ts | 181 ++++++++++++++++++ .../entityMetadataFactRetriever.ts | 68 +++++++ ...s => entityOwnershipFactRetriever.test.ts} | 97 +--------- ...ver.ts => entityOwnershipFactRetriever.ts} | 54 ++---- .../src/service/fact/factRetrievers/index.ts | 4 +- .../techdocsFactRetriever.test.ts | 147 ++++++++++++++ .../factRetrievers/techdocsFactRetriever.ts | 65 +++++++ .../src/service/fact/factRetrievers/utils.ts | 24 +++ 10 files changed, 542 insertions(+), 150 deletions(-) create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts rename plugins/tech-insights-backend/src/service/fact/factRetrievers/{catalogFactRetriever.test.ts => entityOwnershipFactRetriever.test.ts} (61%) rename plugins/tech-insights-backend/src/service/fact/factRetrievers/{catalogFactRetriever.ts => entityOwnershipFactRetriever.ts} (58%) create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts create mode 100644 plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts diff --git a/packages/backend/src/plugins/techInsights.ts b/packages/backend/src/plugins/techInsights.ts index 6194f03410..8ab6342a4f 100644 --- a/packages/backend/src/plugins/techInsights.ts +++ b/packages/backend/src/plugins/techInsights.ts @@ -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, }, ], }, diff --git a/plugins/tech-insights-backend/api-report.md b/plugins/tech-insights-backend/api-report.md index 2ed5919484..ac2d1d1170 100644 --- a/plugins/tech-insights-backend/api-report.md +++ b/plugins/tech-insights-backend/api-report.md @@ -24,14 +24,6 @@ export const buildTechInsightsContext: < options: TechInsightsOptions, ) => Promise>; -// 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): Promise; +// @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, diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts new file mode 100644 index 0000000000..b99f6a1c02 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts @@ -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 = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + 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, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts new file mode 100644 index 0000000000..d6d4752adb --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts @@ -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), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts similarity index 61% rename from plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts rename to plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts index 7b762be63f..ea0801f17c 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts @@ -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, - }, - }); - }); - }); - }); - }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts similarity index 58% rename from plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts rename to plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts index d4d6e54865..5dd3abfaa0 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/catalogFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts @@ -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]), - ), - }), - {}, - ), }, }; }); }, -}); +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts index 58ee8f8e92..587db8c4b9 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts @@ -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'; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts new file mode 100644 index 0000000000..6ed9b12523 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts @@ -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 = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + 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, + }, + }); + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts new file mode 100644 index 0000000000..e5a872c703 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts @@ -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, + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts new file mode 100644 index 0000000000..7967502a63 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts @@ -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])); From 8cb41885f5836e041fb8b5fa22fd386788d477f4 Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 17 Nov 2021 16:49:19 +0000 Subject: [PATCH 10/12] Pass entityFilter to fact retriever handler context Signed-off-by: Iain Billett --- .../src/service/fact/FactRetrieverEngine.test.ts | 7 +++++-- .../src/service/fact/FactRetrieverEngine.ts | 5 ++++- plugins/tech-insights-node/api-report.md | 3 +++ plugins/tech-insights-node/src/facts.ts | 3 +++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts index 4dc002465b..8d1f7180a3 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts @@ -44,7 +44,7 @@ const testFactRetriever: FactRetriever = { description: '', }, }, - handler: async () => { + handler: jest.fn(async () => { return [ { entity: { @@ -57,7 +57,7 @@ const testFactRetriever: FactRetriever = { }, }, ]; - }, + }), }; const cadence = '1 * * * *'; describe('FactRetrieverEngine', () => { @@ -145,5 +145,8 @@ describe('FactRetrieverEngine', () => { const job: any = engine.getJob('test-factretriever'); job.triggerScheduledJobNow(); expect(job.cadence!!).toEqual(cadence); + expect(testFactRetriever.handler).toHaveBeenCalledWith( + expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }), + ); }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts index aa2207cca8..11fd0d29b8 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts @@ -108,7 +108,10 @@ export class FactRetrieverEngine { this.logger.info( `Retrieving facts for fact retriever ${factRetriever.id}`, ); - const facts = await factRetriever.handler(this.factRetrieverContext); + const facts = await factRetriever.handler({ + ...this.factRetrieverContext, + entityFilter: factRetriever.entityFilter, + }); if (this.logger.isDebugEnabled()) { this.logger.debug( `Retrieved ${facts.length} facts for fact retriever ${ diff --git a/plugins/tech-insights-node/api-report.md b/plugins/tech-insights-node/api-report.md index caf4b4cf0e..fc137da4bc 100644 --- a/plugins/tech-insights-node/api-report.md +++ b/plugins/tech-insights-node/api-report.md @@ -53,6 +53,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger_2; + entityFilter?: + | Record[] + | Record; }; // @public diff --git a/plugins/tech-insights-node/src/facts.ts b/plugins/tech-insights-node/src/facts.ts index a52a91f2f4..4a3157d391 100644 --- a/plugins/tech-insights-node/src/facts.ts +++ b/plugins/tech-insights-node/src/facts.ts @@ -136,6 +136,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger; + entityFilter?: + | Record[] + | Record; }; /** From b26d33a9be374982658b525db5e276df0599b19d Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 17 Nov 2021 17:09:33 +0000 Subject: [PATCH 11/12] Update changeset Signed-off-by: Iain Billett --- .changeset/ten-planes-remember.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/ten-planes-remember.md b/.changeset/ten-planes-remember.md index 2887aec568..3fefe650af 100644 --- a/.changeset/ten-planes-remember.md +++ b/.changeset/ten-planes-remember.md @@ -2,7 +2,7 @@ '@backstage/plugin-tech-insights-backend': patch --- -Add a catalog fact retriever +Add catalog fact retrievers -Add a fact retriever which generates facts related to the completeness +Add fact retrievers which generate facts related to the completeness of entity data in the catalog. From 325be576eadd1be19de3bbf10c22bbe0407eb68e Mon Sep 17 00:00:00 2001 From: Iain Billett Date: Wed, 17 Nov 2021 17:37:18 +0000 Subject: [PATCH 12/12] Actually use the entity filters Signed-off-by: Iain Billett --- .../fact/factRetrievers/entityMetadataFactRetriever.ts | 4 ++-- .../fact/factRetrievers/entityOwnershipFactRetriever.ts | 9 +++------ .../service/fact/factRetrievers/techdocsFactRetriever.ts | 4 ++-- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts index d6d4752adb..535aede115 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts @@ -44,11 +44,11 @@ export const entityMetadataFactRetriever: FactRetriever = { description: 'The entity has tags in metadata', }, }, - handler: async ({ discovery }: FactRetrieverContext) => { + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { const catalogClient = new CatalogClient({ discoveryApi: discovery, }); - const entities = await catalogClient.getEntities(); + const entities = await catalogClient.getEntities({ filter: entityFilter }); return entities.items.map((entity: Entity) => { return { diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts index 5dd3abfaa0..8f61f182ad 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts @@ -30,10 +30,7 @@ export const entityOwnershipFactRetriever: FactRetriever = { id: 'entityOwnershipFactRetriever', version: '0.0.1', entityFilter: [ - { - field: 'kind', - values: ['component', 'domain', 'system', 'api', 'resource', 'template'], - }, + { kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] }, ], schema: { hasOwner: { @@ -45,11 +42,11 @@ export const entityOwnershipFactRetriever: FactRetriever = { description: 'The spec.owner field is set and refers to a group', }, }, - handler: async ({ discovery }: FactRetrieverContext) => { + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { const catalogClient = new CatalogClient({ discoveryApi: discovery, }); - const entities = await catalogClient.getEntities(); + const entities = await catalogClient.getEntities({ filter: entityFilter }); return entities.items.map((entity: Entity) => { return { diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts index e5a872c703..c1c3ba5d20 100644 --- a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts @@ -40,11 +40,11 @@ export const techdocsFactRetriever: FactRetriever = { description: 'The entity has a title in metadata', }, }, - handler: async ({ discovery }: FactRetrieverContext) => { + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { const catalogClient = new CatalogClient({ discoveryApi: discovery, }); - const entities = await catalogClient.getEntities(); + const entities = await catalogClient.getEntities({ filter: entityFilter }); return entities.items.map((entity: Entity) => { return {