Merge branch 'master' into scorecard-tech-insights

Signed-off-by: irma12 <irma@roadie.io>
This commit is contained in:
irma12
2021-11-23 12:15:21 +01:00
625 changed files with 10914 additions and 2398 deletions
@@ -0,0 +1,15 @@
# @backstage/plugin-tech-insights-backend
## 0.1.1
### Patch Changes
- 5c00e45045: Add catalog fact retrievers
Add fact retrievers which generate facts related to the completeness
of entity data in the catalog.
- Updated dependencies
- @backstage/catalog-client@0.5.2
- @backstage/catalog-model@0.9.7
- @backstage/backend-common@0.9.10
+2 -2
View File
@@ -22,7 +22,7 @@ do this by creating a file called `packages/backend/src/plugins/techInsights.ts`
```ts
import {
createRouter,
DefaultTechInsightsBuilder,
buildTechInsightsContext,
} from '@backstage/plugin-tech-insights-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -33,7 +33,7 @@ export default async function createPlugin({
discovery,
database,
}: PluginEnvironment): Promise<Router> {
const builder = new DefaultTechInsightsBuilder({
const builder = buildTechInsightsContext({
logger,
config,
database,
@@ -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,
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-tech-insights-backend",
"version": "0.1.0",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,9 +31,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.0",
"@backstage/catalog-client": "^0.3.16",
"@backstage/catalog-model": "^0.9.0",
"@backstage/backend-common": "^0.9.10",
"@backstage/catalog-client": "^0.5.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/config": "^0.1.8",
"@backstage/errors": "^0.1.1",
"@backstage/plugin-tech-insights-common": "^0.1.0",
@@ -52,8 +52,8 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "^0.1.8",
"@backstage/cli": "^0.8.0",
"@backstage/backend-test-utils": "^0.1.9",
"@backstage/cli": "^0.9.0",
"@types/supertest": "^2.0.8",
"@types/node-cron": "^3.0.0",
"@types/semver": "^7.3.8",
@@ -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 ${
@@ -0,0 +1,181 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CatalogListResponse } from '@backstage/catalog-client';
import { entityMetadataFactRetriever } from './entityMetadataFactRetriever';
const getEntitiesMock = jest.fn();
jest.mock('@backstage/catalog-client', () => {
return {
CatalogClient: jest
.fn()
.mockImplementation(() => ({ getEntities: getEntitiesMock })),
};
});
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const defaultEntityListResponse: CatalogListResponse<Entity> = {
items: [
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-owner',
description: 'service with an owner',
tags: ['a-tag'],
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'team-a',
},
relations: [
{
type: RELATION_OWNED_BY,
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-incomplete-data',
description: '',
tags: [],
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: '',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-user-owner',
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'user:my-user',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'user-a',
},
kind: 'User',
spec: {
memberOf: 'group-a',
},
},
],
};
const handlerContext = {
discovery,
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
};
const entityFactRetriever = entityMetadataFactRetriever;
describe('entityMetadataFactRetriever', () => {
beforeEach(() => {
getEntitiesMock.mockResolvedValue(defaultEntityListResponse);
});
afterEach(() => {
getEntitiesMock.mockClear();
});
describe('hasDescription', () => {
describe('where the entity has a description', () => {
it('returns true for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasDescription: true,
},
});
});
});
describe('where the entity does not have a description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
describe('where the entity has an empty value for description', () => {
it('returns false for hasDescription', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasDescription: false,
},
});
});
});
});
describe('hasTags', () => {
describe('where the entity has tags', () => {
it('returns true for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasTags: true,
},
});
});
});
describe('where the entity has no tags', () => {
it('returns false for hasTags', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasTags: false,
},
});
});
});
});
});
@@ -0,0 +1,68 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FactRetriever,
FactRetrieverContext,
} from '@backstage/plugin-tech-insights-node';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import isEmpty from 'lodash/isEmpty';
/**
* Generates facts which indicate the completeness of entity metadata.
*
* @public
*/
export const entityMetadataFactRetriever: FactRetriever = {
id: 'entityMetadataFactRetriever',
version: '0.0.1',
schema: {
hasTitle: {
type: 'boolean',
description: 'The entity has a title in metadata',
},
hasDescription: {
type: 'boolean',
description: 'The entity has a description in metadata',
},
hasTags: {
type: 'boolean',
description: 'The entity has tags in metadata',
},
},
handler: async ({ discovery, 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),
},
};
});
},
};
@@ -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,
},
});
});
});
});
});
@@ -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';
@@ -0,0 +1,147 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { techdocsFactRetriever } from './techdocsFactRetriever';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
PluginEndpointDiscovery,
getVoidLogger,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CatalogListResponse } from '@backstage/catalog-client';
const getEntitiesMock = jest.fn();
jest.mock('@backstage/catalog-client', () => {
return {
CatalogClient: jest
.fn()
.mockImplementation(() => ({ getEntities: getEntitiesMock })),
};
});
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const defaultEntityListResponse: CatalogListResponse<Entity> = {
items: [
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-owner',
description: 'service with an owner',
tags: ['a-tag'],
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'team-a',
},
relations: [
{
type: RELATION_OWNED_BY,
target: { name: 'team-a', kind: 'group', namespace: 'default' },
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-incomplete-data',
description: '',
tags: [],
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: '',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'service-with-user-owner',
},
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'test',
owner: 'user:my-user',
},
},
{
apiVersion: 'backstage.io/v1beta1',
metadata: {
name: 'user-a',
},
kind: 'User',
spec: {
memberOf: 'group-a',
},
},
],
};
const handlerContext = {
discovery,
logger: getVoidLogger(),
config: ConfigReader.fromConfigs([]),
};
const entityFactRetriever = techdocsFactRetriever;
describe('techdocsFactRetriever', () => {
beforeEach(() => {
getEntitiesMock.mockResolvedValue(defaultEntityListResponse);
});
afterEach(() => {
getEntitiesMock.mockClear();
});
describe('hasAnnotationBackstageIoTechdocsRef', () => {
describe('where the retriever is configured to check for the techdocs annotation', () => {
describe('where the entity has the techdocs annotation', () => {
it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-owner'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: true,
},
});
});
});
describe('where the entity is missing the techdocs annotation', () => {
it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => {
const facts = await entityFactRetriever.handler(handlerContext);
expect(
facts.find(it => it.entity.name === 'service-with-incomplete-data'),
).toMatchObject({
facts: {
hasAnnotationBackstageIoTechdocsRef: false,
},
});
});
});
});
});
});
@@ -0,0 +1,65 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FactRetriever,
FactRetrieverContext,
} from '@backstage/plugin-tech-insights-node';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { entityHasAnnotation, generateAnnotationFactName } from './utils';
const techdocsAnnotation = 'backstage.io/techdocs-ref';
const techdocsAnnotationFactName =
generateAnnotationFactName(techdocsAnnotation);
/**
* Generates facts related to the completeness of techdocs configuration for entities.
*
* @public
*/
export const techdocsFactRetriever: FactRetriever = {
id: 'techdocsFactRetriever',
version: '0.0.1',
schema: {
[techdocsAnnotationFactName]: {
type: 'boolean',
description: 'The entity has a title in metadata',
},
},
handler: async ({ discovery, 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]));