Merge pull request #8057 from RoadieHQ/entity-fact-retriever
Catalog fact retriever
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-tech-insights-backend': patch
|
||||
---
|
||||
|
||||
Add catalog fact retrievers
|
||||
|
||||
Add fact retrievers which generate facts related to the completeness
|
||||
of entity data in the catalog.
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
createRouter,
|
||||
buildTechInsightsContext,
|
||||
createFactRetrieverRegistration,
|
||||
entityOwnershipFactRetriever,
|
||||
entityMetadataFactRetriever,
|
||||
techdocsFactRetriever,
|
||||
} 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 +40,12 @@ 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
|
||||
entityOwnershipFactRetriever,
|
||||
),
|
||||
createFactRetrieverRegistration('* * * * *', entityMetadataFactRetriever),
|
||||
createFactRetrieverRegistration('* * * * *', techdocsFactRetriever),
|
||||
],
|
||||
factCheckerFactory: new JsonRulesEngineFactCheckerFactory({
|
||||
checks: [
|
||||
@@ -81,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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -36,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;
|
||||
@@ -52,6 +58,9 @@ export interface RouterOptions<
|
||||
persistenceContext: PersistenceContext;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const techdocsFactRetriever: FactRetriever;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TechInsightsContext<
|
||||
CheckType extends TechInsightCheck,
|
||||
|
||||
@@ -25,3 +25,4 @@ export type {
|
||||
|
||||
export type { PersistenceContext } from './service/persistence/persistenceContext';
|
||||
export { createFactRetrieverRegistration } from './service/fact/createFactRetriever';
|
||||
export * from './service/fact/factRetrievers';
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
+181
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+68
@@ -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, entityFilter }: FactRetrieverContext) => {
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
const entities = await catalogClient.getEntities({ filter: entityFilter });
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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 { entityOwnershipFactRetriever } from './entityOwnershipFactRetriever';
|
||||
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 = entityOwnershipFactRetriever;
|
||||
|
||||
describe('entityFactRetriever', () => {
|
||||
beforeEach(() => {
|
||||
getEntitiesMock.mockResolvedValue(defaultEntityListResponse);
|
||||
});
|
||||
afterEach(() => {
|
||||
getEntitiesMock.mockClear();
|
||||
});
|
||||
|
||||
describe('hasOwner', () => {
|
||||
describe('where the entity has an owner in the spec', () => {
|
||||
it('returns true for hasOwner', async () => {
|
||||
const facts = await entityFactRetriever.handler(handlerContext);
|
||||
expect(
|
||||
facts.find(it => it.entity.name === 'service-with-owner'),
|
||||
).toMatchObject({
|
||||
facts: {
|
||||
hasOwner: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
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-with-incomplete-data'),
|
||||
).toMatchObject({
|
||||
facts: {
|
||||
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('hasGroupOwner', () => {
|
||||
describe('where the entity has an group as owner in the spec', () => {
|
||||
it('returns true for hasGroupOwner', async () => {
|
||||
const facts = await entityFactRetriever.handler(handlerContext);
|
||||
expect(
|
||||
facts.find(it => it.entity.name === 'service-with-owner'),
|
||||
).toMatchObject({
|
||||
facts: {
|
||||
hasGroupOwner: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('where the entity has a user as owner in the spec', () => {
|
||||
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: {
|
||||
hasGroupOwner: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
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-with-incomplete-data'),
|
||||
).toMatchObject({
|
||||
facts: {
|
||||
hasGroupOwner: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+68
@@ -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';
|
||||
|
||||
/**
|
||||
* 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: [
|
||||
{ kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] },
|
||||
],
|
||||
schema: {
|
||||
hasOwner: {
|
||||
type: 'boolean',
|
||||
description: 'The spec.owner field is set',
|
||||
},
|
||||
hasGroupOwner: {
|
||||
type: 'boolean',
|
||||
description: 'The spec.owner field is set and refers to a group',
|
||||
},
|
||||
},
|
||||
handler: async ({ discovery, entityFilter }: FactRetrieverContext) => {
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
const entities = await catalogClient.getEntities({ filter: entityFilter });
|
||||
|
||||
return entities.items.map((entity: Entity) => {
|
||||
return {
|
||||
entity: {
|
||||
namespace: entity.metadata.namespace!!,
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
},
|
||||
facts: {
|
||||
hasOwner: Boolean(entity.spec?.owner),
|
||||
hasGroupOwner: Boolean(
|
||||
entity.spec?.owner &&
|
||||
!(entity.spec?.owner as string).startsWith('user:'),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 './entityOwnershipFactRetriever';
|
||||
export * from './entityMetadataFactRetriever';
|
||||
export * from './techdocsFactRetriever';
|
||||
+147
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+65
@@ -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, entityFilter }: FactRetrieverContext) => {
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
const entities = await catalogClient.getEntities({ filter: entityFilter });
|
||||
|
||||
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]));
|
||||
@@ -53,6 +53,9 @@ export type FactRetrieverContext = {
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
logger: Logger_2;
|
||||
entityFilter?:
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -136,6 +136,9 @@ export type FactRetrieverContext = {
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
logger: Logger;
|
||||
entityFilter?:
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user