Merge branch 'backstage:master' into feature/azure-devops-get-projects

This commit is contained in:
Callen Barton
2022-07-07 10:09:17 -06:00
committed by GitHub
60 changed files with 616 additions and 223 deletions
+1 -1
View File
@@ -40,6 +40,7 @@
"@backstage/config": "^1.0.1",
"@backstage/errors": "^1.1.0-next.0",
"@backstage/types": "^1.0.0",
"@davidzemon/passport-okta-oauth": "^0.0.5",
"@google-cloud/firestore": "^5.0.2",
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
@@ -68,7 +69,6 @@
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^1.0.0",
"passport-oauth2": "^1.6.1",
"passport-okta-oauth": "^0.0.1",
"passport-onelogin-oauth": "^0.0.1",
"passport-saml": "^3.1.2",
"uuid": "^8.0.0",
@@ -26,7 +26,7 @@ import {
OAuthRefreshRequest,
OAuthResult,
} from '../../lib/oauth';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import { Strategy as OktaStrategy } from '@davidzemon/passport-okta-oauth';
import passport from 'passport';
import {
executeFrameHandlerStrategy,
@@ -55,6 +55,8 @@ type PrivateInfo = {
export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
authServerId?: string;
idp?: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
resolverContext: AuthResolverContext;
@@ -94,6 +96,8 @@ export class OktaAuthProvider implements OAuthHandlers {
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
audience: options.audience,
authServerID: options.authServerId,
idp: options.idp,
passReqToCallback: false,
store: this.store,
response_type: 'code',
@@ -220,6 +224,8 @@ export const okta = createAuthProviderIntegration({
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const authServerId = envConfig.getOptionalString('authServerId');
const idp = envConfig.getOptionalString('idp');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
@@ -240,6 +246,8 @@ export const okta = createAuthProviderIntegration({
const provider = new OktaAuthProvider({
audience,
authServerId,
idp,
clientId,
clientSecret,
callbackUrl,
-20
View File
@@ -1,20 +0,0 @@
/*
* Copyright 2020 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.
*/
declare module 'passport-okta-oauth' {
export class Strategy {
constructor(options: any, verify: any);
}
}
+15 -2
View File
@@ -268,6 +268,12 @@ export type CatalogProcessorParser = (options: {
location: LocationSpec;
}) => AsyncIterable<CatalogProcessorResult>;
// @public (undocumented)
export type CatalogProcessorRefreshKeysResult = {
type: 'refresh';
key: string;
};
// @public (undocumented)
export type CatalogProcessorRelationResult = {
type: 'relation';
@@ -279,7 +285,8 @@ export type CatalogProcessorResult =
| CatalogProcessorLocationResult
| CatalogProcessorEntityResult
| CatalogProcessorRelationResult
| CatalogProcessorErrorResult;
| CatalogProcessorErrorResult
| CatalogProcessorRefreshKeysResult;
// @public (undocumented)
export class CodeOwnersProcessor implements CatalogProcessor {
@@ -545,7 +552,11 @@ export class PlaceholderProcessor implements CatalogProcessor {
// (undocumented)
getProcessorName(): string;
// (undocumented)
preProcessEntity(entity: Entity, location: LocationSpec): Promise<Entity>;
preProcessEntity(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity>;
}
// @public (undocumented)
@@ -567,6 +578,7 @@ export type PlaceholderResolverParams = {
baseUrl: string;
read: PlaceholderResolverRead;
resolveUrl: PlaceholderResolverResolveUrl;
emit: CatalogProcessorEmit;
};
// @public (undocumented)
@@ -601,6 +613,7 @@ export const processingResult: Readonly<{
newEntity: Entity,
) => CatalogProcessorResult;
readonly relation: (spec: EntityRelationSpec) => CatalogProcessorResult;
readonly refresh: (key: string) => CatalogProcessorResult;
}>;
// @public (undocumented)
@@ -0,0 +1,53 @@
/*
* Copyright 2022 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.
*/
/**
* @param { import("knex").Knex } knex
*/
exports.up = async function up(knex) {
await knex.schema.createTable('refresh_keys', table => {
table.comment(
'This table contains relations between entities and keys to trigger refreshes with',
);
table
.text('entity_id')
.notNullable()
.references('entity_id')
.inTable('refresh_state')
.onDelete('CASCADE')
.comment('A reference to the entity that the refresh key is tied to');
table
.text('key')
.notNullable()
.comment(
'A reference to a key which should be used to trigger a refresh on this entity',
);
table.index('entity_id', 'refresh_keys_entity_id_idx');
table.index('key', 'refresh_keys_key_idx');
});
};
/**
* @param { import("knex").Knex } knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('refresh_keys', table => {
table.dropIndex([], 'refresh_keys_entity_id_idx');
table.dropIndex([], 'refresh_keys_key_idx');
});
await knex.schema.dropTable('refresh_keys');
};
+1
View File
@@ -26,6 +26,7 @@ export type {
CatalogProcessorRelationResult,
CatalogProcessorErrorResult,
CatalogProcessorResult,
CatalogProcessorRefreshKeysResult,
} from './processor';
export type {
EntityProvider,
@@ -65,4 +65,8 @@ export const processingResult = Object.freeze({
relation(spec: EntityRelationSpec): CatalogProcessorResult {
return { type: 'relation', relation: spec };
},
refresh(key: string): CatalogProcessorResult {
return { type: 'refresh', key };
},
} as const);
+8 -1
View File
@@ -169,9 +169,16 @@ export type CatalogProcessorErrorResult = {
location: LocationSpec;
};
/** @public */
export type CatalogProcessorRefreshKeysResult = {
type: 'refresh';
key: string;
};
/** @public */
export type CatalogProcessorResult =
| CatalogProcessorLocationResult
| CatalogProcessorEntityResult
| CatalogProcessorRelationResult
| CatalogProcessorErrorResult;
| CatalogProcessorErrorResult
| CatalogProcessorRefreshKeysResult;
@@ -24,6 +24,7 @@ import { DateTime } from 'luxon';
import { applyDatabaseMigrations } from './migrations';
import { DefaultProcessingDatabase } from './DefaultProcessingDatabase';
import {
DbRefreshKeysRow,
DbRefreshStateReferencesRow,
DbRefreshStateRow,
DbRelationsRow,
@@ -98,6 +99,7 @@ describe('Default Processing Database', () => {
resultHash: '',
relations: [],
deferredEntities: [],
refreshKeys: [],
}),
).rejects.toThrow(
`Conflicting write of processing result for ${id} with location key 'undefined'`,
@@ -117,6 +119,7 @@ describe('Default Processing Database', () => {
relations: [],
deferredEntities: [],
locationKey: 'key',
refreshKeys: [],
errors: "['something broke']",
};
const { knex, db } = await createDatabase(databaseId);
@@ -143,6 +146,7 @@ describe('Default Processing Database', () => {
...options,
resultHash: '',
locationKey: 'fail',
refreshKeys: [],
}),
).rejects.toThrow(
`Conflicting write of processing result for ${id} with location key 'fail'`,
@@ -174,6 +178,7 @@ describe('Default Processing Database', () => {
relations: [],
deferredEntities: [],
locationKey: 'key',
refreshKeys: [],
errors: "['something broke']",
}),
);
@@ -228,6 +233,7 @@ describe('Default Processing Database', () => {
resultHash: '',
relations: relations,
deferredEntities: [],
refreshKeys: [],
}),
);
@@ -250,6 +256,7 @@ describe('Default Processing Database', () => {
resultHash: '',
relations: relations,
deferredEntities: [],
refreshKeys: [],
}),
);
@@ -309,6 +316,7 @@ describe('Default Processing Database', () => {
resultHash: '',
relations: [],
deferredEntities,
refreshKeys: [],
}),
);
@@ -400,6 +408,7 @@ describe('Default Processing Database', () => {
processedEntity,
resultHash: '',
relations: [],
refreshKeys: [],
deferredEntities: [
{
entity: {
@@ -465,6 +474,63 @@ describe('Default Processing Database', () => {
},
60_000,
);
it.each(databases.eachSupportedId())(
'stores the refresh keys for the entity',
async databaseId => {
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
const { knex, db } = await createDatabase(
databaseId,
mockLogger as unknown as Logger,
);
await insertRefreshStateRow(knex, {
entity_id: id,
entity_ref: 'location:default/fakelocation',
unprocessed_entity: '{}',
processed_entity: '{}',
errors: '[]',
next_update_at: '2021-04-01 13:37:00',
last_discovery_at: '2021-04-01 13:37:00',
});
const deferredEntities = [
{
entity: {
apiVersion: '1',
kind: 'Location',
metadata: {
name: 'next',
},
},
locationKey: 'mock',
},
];
await db.transaction(tx =>
db.updateProcessedEntity(tx, {
id,
processedEntity,
resultHash: '',
relations: [],
deferredEntities,
refreshKeys: [{ key: 'protocol:foo-bar.com' }],
}),
);
const refreshKeys = await knex<DbRefreshKeysRow>('refresh_keys')
.where({ entity_id: id })
.select();
expect(refreshKeys[0]).toEqual({
entity_id: id,
key: 'protocol:foo-bar.com',
});
},
);
});
describe('updateEntityCache', () => {
@@ -33,12 +33,14 @@ import {
UpdateEntityCacheOptions,
ListParentsOptions,
ListParentsResult,
RefreshByKeyOptions,
} from './types';
import { DeferredEntity } from '../processing/types';
import { ProcessingIntervalFunction } from '../processing/refresh';
import { rethrowError, timestampToDateTime } from './conversion';
import { initDatabaseMetrics } from './metrics';
import {
DbRefreshKeysRow,
DbRefreshStateReferencesRow,
DbRefreshStateRow,
DbRelationsRow,
@@ -77,6 +79,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
errors,
relations,
deferredEntities,
refreshKeys,
locationKey,
} = options;
const refreshResult = await tx<DbRefreshStateRow>('refresh_state')
@@ -100,11 +103,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
`Conflicting write of processing result for ${id} with location key '${locationKey}'`,
);
}
const sourceEntityRef = stringifyEntityRef(processedEntity);
// Schedule all deferred entities for future processing.
await this.addUnprocessedEntities(tx, {
entities: deferredEntities,
sourceEntityRef: stringifyEntityRef(processedEntity),
sourceEntityRef,
});
// Delete old relations
@@ -138,6 +142,21 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
BATCH_SIZE,
);
// Delete old refresh keys
await tx<DbRefreshKeysRow>('refresh_keys')
.where({ entity_id: id })
.delete();
// Insert the refresh keys for the processed entity
await tx.batchInsert(
'refresh_keys',
refreshKeys.map(k => ({
entity_id: id,
key: k.key,
})),
BATCH_SIZE,
);
return {
previous: {
relations: previousRelationRows,
@@ -516,6 +535,25 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
}
}
async refreshByRefreshKeys(
txOpaque: Transaction,
options: RefreshByKeyOptions,
) {
const tx = txOpaque as Knex.Transaction;
const { keys } = options;
await tx<DbRefreshStateRow>('refresh_state')
.whereIn('entity_id', function selectEntityRefs(tx2) {
tx2
.whereIn('key', keys)
.select({
entity_id: 'refresh_keys.entity_id',
})
.from('refresh_keys');
})
.update({ next_update_at: tx.fn.now() });
}
async transaction<T>(fn: (tx: Transaction) => Promise<T>): Promise<T> {
try {
let result: T | undefined = undefined;
@@ -43,6 +43,11 @@ export type DbRefreshStateRow = {
location_key?: string;
};
export type DbRefreshKeysRow = {
entity_id: string;
key: string;
};
export type DbRefreshStateReferencesRow = {
source_key?: string;
source_entity_ref?: string;
@@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/types';
import { DateTime } from 'luxon';
import { EntityRelationSpec } from '../api';
import { DeferredEntity } from '../processing/types';
import { DeferredEntity, RefreshKeyData } from '../processing/types';
import { DbRelationsRow } from './tables';
/**
@@ -38,6 +38,7 @@ export type UpdateProcessedEntityOptions = {
relations: EntityRelationSpec[];
deferredEntities: DeferredEntity[];
locationKey?: string;
refreshKeys: RefreshKeyData[];
};
export type UpdateEntityCacheOptions = {
@@ -81,6 +82,10 @@ export type ReplaceUnprocessedEntitiesOptions =
type: 'delta';
};
export type RefreshByKeyOptions = {
keys: string[];
};
export type RefreshOptions = {
entityRef: string;
};
@@ -43,7 +43,10 @@ describe('FileReaderProcessor', () => {
expect(generated.type).toBe('entity');
expect(generated.location).toEqual(spec);
expect(generated.entity).toEqual({ kind: 'Component' });
expect(generated.entity).toEqual({
kind: 'Component',
metadata: { name: 'component-test' },
});
});
it('should fail load from file with error', async () => {
@@ -77,14 +80,24 @@ describe('FileReaderProcessor', () => {
defaultEntityDataParser,
);
expect(emit).toBeCalledTimes(2);
expect(emit.mock.calls[0][0].entity).toEqual({ kind: 'Component' });
expect(emit).toBeCalledTimes(4);
expect(emit.mock.calls[0][0].entity).toEqual({
kind: 'Component',
metadata: { name: 'component-test' },
});
expect(emit.mock.calls[0][0].location).toEqual({
type: 'file',
target: expect.stringMatching(/^[^*]*$/),
});
expect(emit.mock.calls[1][0].entity).toEqual({ kind: 'API' });
expect(emit.mock.calls[1][0].location).toEqual({
expect(emit.mock.calls[1][0].key).toContain('file:');
expect(emit.mock.calls[1][0].key).toContain(
'fileReaderProcessor/component.yaml',
);
expect(emit.mock.calls[2][0].entity).toEqual({
kind: 'API',
metadata: { name: 'api-test' },
});
expect(emit.mock.calls[2][0].location).toEqual({
type: 'file',
target: expect.stringMatching(/^[^*]*$/),
});
@@ -28,6 +28,8 @@ import {
const glob = promisify(g);
const LOCATION_TYPE = 'file';
/** @public */
export class FileReaderProcessor implements CatalogProcessor {
getProcessorName(): string {
@@ -40,7 +42,7 @@ export class FileReaderProcessor implements CatalogProcessor {
emit: CatalogProcessorEmit,
parser: CatalogProcessorParser,
): Promise<boolean> {
if (location.type !== 'file') {
if (location.type !== LOCATION_TYPE) {
return false;
}
@@ -50,17 +52,23 @@ export class FileReaderProcessor implements CatalogProcessor {
if (fileMatches.length > 0) {
for (const fileMatch of fileMatches) {
const data = await fs.readFile(fileMatch);
const normalizedFilePath = path.normalize(fileMatch);
// The normalize converts to native slashes; the glob library returns
// forward slashes even on windows
for await (const parseResult of parser({
data: data,
location: {
type: 'file',
target: path.normalize(fileMatch),
type: LOCATION_TYPE,
target: normalizedFilePath,
},
})) {
emit(parseResult);
emit(
processingResult.refresh(
`${LOCATION_TYPE}:${normalizedFilePath}`,
),
);
}
}
} else if (!optional) {
@@ -17,6 +17,7 @@ import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { CatalogProcessorResult } from '../../api';
import {
jsonPlaceholderResolver,
PlaceholderProcessor,
@@ -51,7 +52,7 @@ describe('PlaceholderProcessor', () => {
integrations,
});
await expect(
processor.preProcessEntity(input, { type: 't', target: 'l' }),
processor.preProcessEntity(input, { type: 't', target: 'l' }, () => {}),
).resolves.toBe(input);
});
@@ -76,6 +77,7 @@ describe('PlaceholderProcessor', () => {
spec: { a: [{ b: { $upper: 'text' } }] },
},
{ type: 'fake', target: 'http://example.com' },
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -110,7 +112,7 @@ describe('PlaceholderProcessor', () => {
};
await expect(
processor.preProcessEntity(entity, { type: 'a', target: 'b' }),
processor.preProcessEntity(entity, { type: 'a', target: 'b' }, () => {}),
).resolves.toEqual(entity);
expect(read).not.toBeCalled();
@@ -131,7 +133,7 @@ describe('PlaceholderProcessor', () => {
};
await expect(
processor.preProcessEntity(entity, { type: 'a', target: 'b' }),
processor.preProcessEntity(entity, { type: 'a', target: 'b' }, () => {}),
).resolves.toEqual(entity);
expect(read).not.toBeCalled();
@@ -158,6 +160,7 @@ describe('PlaceholderProcessor', () => {
target:
'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
},
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -194,6 +197,7 @@ describe('PlaceholderProcessor', () => {
target:
'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
},
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -228,6 +232,7 @@ describe('PlaceholderProcessor', () => {
target:
'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
},
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -266,6 +271,7 @@ describe('PlaceholderProcessor', () => {
target:
'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
},
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -303,6 +309,7 @@ describe('PlaceholderProcessor', () => {
type: 'url',
target: './a/b/catalog-info.yaml',
},
() => {},
),
).resolves.toEqual({
apiVersion: 'a',
@@ -343,6 +350,7 @@ describe('PlaceholderProcessor', () => {
type: 'url',
target: './a/b/catalog-info.yaml',
},
() => {},
),
).rejects.toThrow(
/^Placeholder \$text could not form a URL out of \.\/a\/b\/catalog-info\.yaml and \.\.\/c\/catalog-info\.yaml, TypeError \[ERR_INVALID_URL\]/,
@@ -350,6 +358,35 @@ describe('PlaceholderProcessor', () => {
expect(read).not.toBeCalled();
});
it('should emit the resolverValue as a refreshKey', async () => {
read.mockResolvedValue(
Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'),
);
const processor = new PlaceholderProcessor({
resolvers: {
json: jsonPlaceholderResolver,
},
reader,
integrations,
});
const emitted = new Array<CatalogProcessorResult>();
await processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n' },
spec: { a: [{ b: { $json: './path-to-file.json' } }] },
},
{ type: 'fake', target: 'http://example.com' },
result => emitted.push(result),
);
expect(emitted[0]).toEqual({
type: 'refresh',
key: 'url:http://example.com/path-to-file.json',
});
});
});
describe('yamlPlaceholderResolver', () => {
@@ -360,6 +397,7 @@ describe('yamlPlaceholderResolver', () => {
baseUrl: 'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
read,
resolveUrl: (url, base) => integrations.resolveUrl({ url, base }),
emit: () => {},
};
beforeEach(() => {
@@ -405,6 +443,7 @@ describe('jsonPlaceholderResolver', () => {
baseUrl: 'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
read,
resolveUrl: (url, base) => integrations.resolveUrl({ url, base }),
emit: () => {},
};
beforeEach(() => {
@@ -19,7 +19,12 @@ import { Entity } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/types';
import { ScmIntegrationRegistry } from '@backstage/integration';
import yaml from 'yaml';
import { CatalogProcessor, LocationSpec } from '../../api';
import {
CatalogProcessor,
CatalogProcessorEmit,
LocationSpec,
processingResult,
} from '../../api';
/** @public */
export type PlaceholderResolverRead = (url: string) => Promise<Buffer>;
@@ -37,6 +42,7 @@ export type PlaceholderResolverParams = {
baseUrl: string;
read: PlaceholderResolverRead;
resolveUrl: PlaceholderResolverResolveUrl;
emit: CatalogProcessorEmit;
};
/** @public */
@@ -66,6 +72,7 @@ export class PlaceholderProcessor implements CatalogProcessor {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
const process = async (data: any): Promise<[any, boolean]> => {
if (!data || !(data instanceof Object)) {
@@ -102,6 +109,7 @@ export class PlaceholderProcessor implements CatalogProcessor {
const resolverKey = keys[0].substr(1);
const resolverValue = data[keys[0]];
const resolver = this.options.resolvers[resolverKey];
if (!resolver || typeof resolverValue !== 'string') {
// If there was no such placeholder resolver or if the value was not a
@@ -134,6 +142,7 @@ export class PlaceholderProcessor implements CatalogProcessor {
baseUrl: location.target,
read,
resolveUrl,
emit,
}),
true,
];
@@ -151,11 +160,13 @@ export class PlaceholderProcessor implements CatalogProcessor {
export async function yamlPlaceholderResolver(
params: PlaceholderResolverParams,
): Promise<JsonValue> {
const text = await readTextLocation(params);
const { content, url } = await readTextLocation(params);
params.emit(processingResult.refresh(`url:${url}`));
let documents: yaml.Document.Parsed[];
try {
documents = yaml.parseAllDocuments(text).filter(d => d);
documents = yaml.parseAllDocuments(content).filter(d => d);
} catch (e) {
throw new Error(
`Placeholder \$${params.key} failed to parse YAML data at ${params.value}, ${e}`,
@@ -182,10 +193,12 @@ export async function yamlPlaceholderResolver(
export async function jsonPlaceholderResolver(
params: PlaceholderResolverParams,
): Promise<JsonValue> {
const text = await readTextLocation(params);
const { content, url } = await readTextLocation(params);
params.emit(processingResult.refresh(`url:${url}`));
try {
return JSON.parse(text);
return JSON.parse(content);
} catch (e) {
throw new Error(
`Placeholder \$${params.key} failed to parse JSON data at ${params.value}, ${e}`,
@@ -196,7 +209,11 @@ export async function jsonPlaceholderResolver(
export async function textPlaceholderResolver(
params: PlaceholderResolverParams,
): Promise<JsonValue> {
return await readTextLocation(params);
const { content, url } = await readTextLocation(params);
params.emit(processingResult.refresh(`url:${url}`));
return content;
}
/*
@@ -205,12 +222,12 @@ export async function textPlaceholderResolver(
async function readTextLocation(
params: PlaceholderResolverParams,
): Promise<string> {
): Promise<{ content: string; url: string }> {
const newUrl = relativeUrl(params);
try {
const data = await params.read(newUrl);
return data.toString('utf-8');
return { content: data.toString('utf-8'), url: newUrl };
} catch (e) {
throw new Error(
`Placeholder \$${params.key} could not read location ${params.value}, ${e}`,
@@ -61,7 +61,13 @@ describe('UrlReaderProcessor', () => {
server.use(
rest.get(`${mockApiOrigin}/component.yaml`, (_, res, ctx) =>
res(ctx.set({ ETag: 'my-etag' }), ctx.json({ mock: 'entity' })),
res(
ctx.set({ ETag: 'my-etag' }),
ctx.json({
kind: 'component',
metadata: { name: 'mock-url-entity' },
}),
),
),
);
@@ -74,15 +80,25 @@ describe('UrlReaderProcessor', () => {
mockCache,
);
expect(emitted.length).toBe(1);
expect(emitted.length).toBe(2);
expect(emitted[0]).toEqual({
type: 'entity',
location: spec,
entity: { mock: 'entity' },
entity: { kind: 'component', metadata: { name: 'mock-url-entity' } },
});
expect(emitted[1]).toEqual({
type: 'refresh',
key: 'url:http://localhost/component.yaml',
});
expect(mockCache.set).toBeCalledWith('v1', {
etag: 'my-etag',
value: [{ type: 'entity', location: spec, entity: { mock: 'entity' } }],
value: [
{
type: 'entity',
location: spec,
entity: { kind: 'component', metadata: { name: 'mock-url-entity' } },
},
],
});
expect(mockCache.set).toBeCalledTimes(1);
});
@@ -93,6 +93,8 @@ export class UrlReaderProcessor implements CatalogProcessor {
value: parseResults as CatalogProcessorEntityResult[],
});
}
emit(processingResult.refresh(`${location.type}:${location.target}`));
} catch (error) {
assertError(error);
const message = `Unable to read ${location.type}, ${error}`;
@@ -1 +1,3 @@
kind: Component
metadata:
name: component-test
@@ -1 +1,3 @@
kind: API
metadata:
name: api-test
@@ -30,6 +30,7 @@ describe('DefaultCatalogProcessingEngine', () => {
updateProcessedEntity: jest.fn(),
updateEntityCache: jest.fn(),
listParents: jest.fn(),
setRefreshKeys: jest.fn(),
} as unknown as jest.Mocked<DefaultProcessingDatabase>;
const orchestrator: jest.Mocked<CatalogProcessingOrchestrator> = {
process: jest.fn(),
@@ -58,6 +59,7 @@ describe('DefaultCatalogProcessingEngine', () => {
errors: [],
deferredEntities: [],
state: {},
refreshKeys: [],
});
const engine = new DefaultCatalogProcessingEngine(
getVoidLogger(),
@@ -123,6 +125,7 @@ describe('DefaultCatalogProcessingEngine', () => {
errors: [],
deferredEntities: [],
state: {},
refreshKeys: [],
});
const engine = new DefaultCatalogProcessingEngine(
getVoidLogger(),
@@ -203,6 +206,7 @@ describe('DefaultCatalogProcessingEngine', () => {
errors: [],
deferredEntities: [],
state: {},
refreshKeys: [],
});
const engine = new DefaultCatalogProcessingEngine(
@@ -413,6 +417,7 @@ describe('DefaultCatalogProcessingEngine', () => {
errors: [],
deferredEntities: [],
state: {},
refreshKeys: [],
})
.mockResolvedValueOnce({
ok: true,
@@ -432,6 +437,7 @@ describe('DefaultCatalogProcessingEngine', () => {
errors: [],
deferredEntities: [],
state: {},
refreshKeys: [],
});
await engine.start();
@@ -134,6 +134,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
.update(stableStringify({ ...result.completedEntity }))
.update(stableStringify([...result.deferredEntities]))
.update(stableStringify([...result.relations]))
.update(stableStringify([...result.refreshKeys]))
.update(stableStringify([...parents]));
}
@@ -180,6 +181,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
relations: result.relations,
deferredEntities: result.deferredEntities,
locationKey,
refreshKeys: result.refreshKeys,
});
oldRelationSources = new Set(
previous.relations.map(r => r.source_entity_ref),
@@ -102,6 +102,7 @@ describe('DefaultCatalogProcessingOrchestrator', () => {
ok: true,
completedEntity: entity,
deferredEntities: [],
refreshKeys: [],
errors: [],
relations: [],
state: {
@@ -119,6 +120,7 @@ describe('DefaultCatalogProcessingOrchestrator', () => {
).resolves.toEqual({
ok: true,
completedEntity: entity,
refreshKeys: [],
deferredEntities: [
{
locationKey: 'url:./new-place',
@@ -24,7 +24,7 @@ import { assertError } from '@backstage/errors';
import { Logger } from 'winston';
import { CatalogProcessorResult, EntityRelationSpec } from '../api';
import { locationSpecToLocationEntity } from '../util/conversion';
import { DeferredEntity } from './types';
import { DeferredEntity, RefreshKeyData } from './types';
import {
getEntityLocationRef,
getEntityOriginLocationRef,
@@ -38,6 +38,7 @@ export class ProcessorOutputCollector {
private readonly errors = new Array<Error>();
private readonly relations = new Array<EntityRelationSpec>();
private readonly deferredEntities = new Array<DeferredEntity>();
private readonly refreshKeys = new Array<RefreshKeyData>();
private done = false;
constructor(
@@ -54,6 +55,7 @@ export class ProcessorOutputCollector {
return {
errors: this.errors,
relations: this.relations,
refreshKeys: this.refreshKeys,
deferredEntities: this.deferredEntities,
};
}
@@ -116,6 +118,8 @@ export class ProcessorOutputCollector {
this.relations.push(i.relation);
} else if (i.type === 'error') {
this.errors.push(i.error);
} else if (i.type === 'refresh') {
this.refreshKeys.push({ key: i.key });
}
}
}
@@ -26,7 +26,6 @@ export type EntityProcessingRequest = {
entity: Entity;
state?: JsonObject; // Versions for multiple deployments etc
};
/**
* The result of processing an entity.
* @public
@@ -38,6 +37,7 @@ export type EntityProcessingResult =
completedEntity: Entity;
deferredEntities: DeferredEntity[];
relations: EntityRelationSpec[];
refreshKeys: RefreshKeyData[];
errors: Error[];
}
| {
@@ -45,6 +45,14 @@ export type EntityProcessingResult =
errors: Error[];
};
/**
* A string to associate to the entity itself.
* @public
*/
export type RefreshKeyData = {
key: string;
};
/**
* Responsible for executing the individual processing steps in order to fully process an entity.
* @public
@@ -48,6 +48,7 @@ describe('DefaultLocationServiceTest', () => {
name: 'foo',
},
},
refreshKeys: [],
deferredEntities: [
{
entity: {
@@ -75,6 +76,7 @@ describe('DefaultLocationServiceTest', () => {
},
},
deferredEntities: [],
refreshKeys: [],
relations: [],
errors: [],
});
@@ -134,6 +136,7 @@ describe('DefaultLocationServiceTest', () => {
},
},
deferredEntities: [],
refreshKeys: [],
relations: [],
errors: [],
});
@@ -161,6 +164,7 @@ describe('DefaultLocationServiceTest', () => {
name: 'foo',
},
},
refreshKeys: [],
deferredEntities: [
{
entity: {
@@ -188,6 +192,7 @@ describe('DefaultLocationServiceTest', () => {
},
},
deferredEntities: [],
refreshKeys: [],
relations: [],
errors: [],
});
@@ -211,6 +216,7 @@ describe('DefaultLocationServiceTest', () => {
name: 'bar',
},
},
refreshKeys: [],
deferredEntities: [],
relations: [],
errors: [],
@@ -138,6 +138,7 @@ describe('Refresh integration', () => {
errors: [],
deferredEntities,
state: {},
refreshKeys: [],
};
},
},
@@ -409,6 +409,7 @@ describe('createRouter readonly disabled', () => {
state: {},
completedEntity: entity,
deferredEntities: [],
refreshKeys: [],
relations: [],
errors: [],
});
@@ -2,48 +2,34 @@
This can be used to run the kubernetes plugin locally against a mock service.
# Viewing in local Minikube running Backstage locally
# Viewing in local Kind running Backstage locally
## Prerequisites
- kubectl installed
- Minikube installed, with the following addons
- metrics-server
- ingress
- jq installed
- [kubectl installed](https://kubernetes.io/docs/tasks/tools/#kubectl)
- [Kind installed](https://kind.sigs.k8s.io/docs/user/quick-start/)
- Backstage locally built and ready to run
## Steps
1. Start minikube
2. Get the Kubernetes master base URL `kubectl cluster-info`
3. Apply manifests `kubectl apply -f dice-roller-manifests.yaml`
4. Get service account token (see below)
5. Start Backstage UI and backend
6. Register existing component in Backstage
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
1. Start kind
2. Apply manifests `kubectl apply -f plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml`
3. Run `kubectl proxy`
4. In separate terminal windows start Backstage UI and backend
5. Register a test component ([example](https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml))
6. Visit [kubernetes plugin page](http://localhost:3000/catalog/default/component/dice-roller/kubernetes)
Add or update `app-config.local.yaml` with the following:
### Example `app-config.local.yaml`
```yaml
kubernetes:
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- type: 'config'
clusters:
- url: <KUBERNETES MASTER BASE URL FROM STEP 2>
name: minikube
serviceAccountToken: <TOKEN FROM STEP 4>
authProvider: 'serviceAccount'
- type: 'localKubectlProxy'
catalog:
locations:
- type: url
target: https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
```
### Getting the service account token
Mac copy to clipboard:
```
kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r '.secrets[0].name') -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
```
Paste into `app-config.local.yaml` `kubernetes.clusters[0].serviceAccountToken`
@@ -14,16 +14,25 @@
* limitations under the License.
*/
import { KubernetesAuthProvider } from './types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
export class GoogleServiceAccountAuthProvider
implements KubernetesAuthProvider
export class LocalKubectlProxyClusterLocator
implements KubernetesClustersSupplier
{
async decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
// No-op, with google service account auth, server's AWS credentials are used for access
return requestBody;
private readonly clusterDetails: ClusterDetails[];
public constructor() {
this.clusterDetails = [
{
name: 'local',
url: 'http:/localhost:8001',
authProvider: 'localKubectlProxy',
skipMetricsLookup: true,
},
];
}
async getClusters(): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
}
@@ -19,6 +19,7 @@ import { Duration } from 'luxon';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { ConfigClusterLocator } from './ConfigClusterLocator';
import { GkeClusterLocator } from './GkeClusterLocator';
import { LocalKubectlProxyClusterLocator } from './LocalKubectlProxyLocator';
class CombinedClustersSupplier implements KubernetesClustersSupplier {
constructor(readonly clusterSuppliers: KubernetesClustersSupplier[]) {}
@@ -45,6 +46,8 @@ export const getCombinedClusterSupplier = (
.map(clusterLocatorMethod => {
const type = clusterLocatorMethod.getString('type');
switch (type) {
case 'localKubectlProxy':
return new LocalKubectlProxyClusterLocator();
case 'config':
return ConfigClusterLocator.fromConfig(clusterLocatorMethod);
case 'gke':
@@ -49,6 +49,9 @@ export class KubernetesAuthTranslatorGenerator {
case 'oidc': {
return new OidcKubernetesAuthTranslator();
}
case 'localKubectlProxy': {
return new NoopKubernetesAuthTranslator();
}
default: {
throw new Error(
`authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
+3 -27
View File
@@ -32,17 +32,6 @@ import { V1ReplicaSet } from '@kubernetes/client-node';
import { V1Service } from '@kubernetes/client-node';
import { V1StatefulSet } from '@kubernetes/client-node';
// Warning: (ae-forgotten-export) The symbol "KubernetesAuthProvider" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "AwsKubernetesAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class AwsKubernetesAuthProvider implements KubernetesAuthProvider {
// (undocumented)
decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody>;
}
// Warning: (ae-forgotten-export) The symbol "ClusterProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "Cluster" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -147,6 +136,7 @@ export function formatClusterLink(
options: FormatClusterLinkOptions,
): string | undefined;
// Warning: (ae-forgotten-export) The symbol "KubernetesAuthProvider" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "GoogleKubernetesAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -160,18 +150,6 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
): Promise<KubernetesRequestBody>;
}
// Warning: (ae-missing-release-tag) "GoogleServiceAccountAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class GoogleServiceAccountAuthProvider
implements KubernetesAuthProvider
{
// (undocumented)
decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody>;
}
// Warning: (ae-missing-release-tag) "GroupedResponses" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -386,10 +364,8 @@ export const PodsTable: ({
// @public (undocumented)
export const Router: (props: { refreshIntervalMs?: number }) => JSX.Element;
// Warning: (ae-missing-release-tag) "ServiceAccountKubernetesAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class ServiceAccountKubernetesAuthProvider
// @public
export class ServerSideKubernetesAuthProvider
implements KubernetesAuthProvider
{
// (undocumented)
@@ -1,27 +0,0 @@
/*
* Copyright 2020 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 { KubernetesAuthProvider } from './types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class AwsKubernetesAuthProvider implements KubernetesAuthProvider {
async decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
// No-op, with aws auth, server's AWS credentials are used for access
return requestBody;
}
}
@@ -1,27 +0,0 @@
/*
* Copyright 2020 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 { KubernetesAuthProvider } from './types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class AzureKubernetesAuthProvider implements KubernetesAuthProvider {
async decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
// No-op, with azure auth, server's Azure credentials are used for access
return requestBody;
}
}
@@ -17,11 +17,8 @@
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types';
import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider';
import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider';
import { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider';
import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider';
import { OAuthApi, OpenIdConnectApi } from '@backstage/core-plugin-api';
import { GoogleServiceAccountAuthProvider } from './GoogleServiceAccountAuthProvider';
import { AzureKubernetesAuthProvider } from './AzureKubernetesAuthProvider';
import { OidcKubernetesAuthProvider } from './OidcKubernetesAuthProvider';
export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
@@ -41,16 +38,23 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
);
this.kubernetesAuthProviderMap.set(
'serviceAccount',
new ServiceAccountKubernetesAuthProvider(),
new ServerSideKubernetesAuthProvider(),
);
this.kubernetesAuthProviderMap.set(
'googleServiceAccount',
new GoogleServiceAccountAuthProvider(),
new ServerSideKubernetesAuthProvider(),
);
this.kubernetesAuthProviderMap.set(
'aws',
new ServerSideKubernetesAuthProvider(),
);
this.kubernetesAuthProviderMap.set('aws', new AwsKubernetesAuthProvider());
this.kubernetesAuthProviderMap.set(
'azure',
new AzureKubernetesAuthProvider(),
new ServerSideKubernetesAuthProvider(),
);
this.kubernetesAuthProviderMap.set(
'localKubectlProxy',
new ServerSideKubernetesAuthProvider(),
);
if (options.oidcProviders) {
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2022 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.
@@ -17,13 +17,18 @@
import { KubernetesAuthProvider } from './types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class ServiceAccountKubernetesAuthProvider
/**
* No-op KubernetesAuthProvider, authorization will be handled in the kubernetes-backend plugin
*
* @public
*/
export class ServerSideKubernetesAuthProvider
implements KubernetesAuthProvider
{
async decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
// No-op, with service account for auth, cluster config/details should already have serviceAccountToken
// No-op, auth will be taken care of on the server-side
return requestBody;
}
}
@@ -17,7 +17,5 @@
export { kubernetesAuthProvidersApiRef } from './types';
export type { KubernetesAuthProvidersApi } from './types';
export { KubernetesAuthProviders } from './KubernetesAuthProviders';
export { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider';
export { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider';
export { GoogleServiceAccountAuthProvider } from './GoogleServiceAccountAuthProvider';
export { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider';
export { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider';
+1 -1
View File
@@ -45,7 +45,7 @@
"@backstage/types": "^1.0.0",
"@gitbeaker/core": "^35.6.0",
"@gitbeaker/node": "^35.1.0",
"@octokit/webhooks": "^9.14.1",
"@octokit/webhooks": "^10.0.0",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
"command-exists": "^1.2.9",
@@ -133,11 +133,13 @@ export const ScaffolderPageContents = ({
export const ScaffolderPage = ({
TemplateCardComponent,
groups,
contextMenu,
}: ScaffolderPageProps) => (
<EntityListProvider>
<ScaffolderPageContents
TemplateCardComponent={TemplateCardComponent}
groups={groups}
contextMenu={contextMenu}
/>
</EntityListProvider>
);
@@ -89,12 +89,12 @@ export const RepoUrlPicker = (
/* we deal with calling the repo setting here instead of in each components for ease */
useEffect(() => {
if (allowedOwners.length === 1) {
if (allowedOwners.length > 0) {
setState(prevState => ({ ...prevState, owner: allowedOwners[0] }));
}
}, [setState, allowedOwners]);
useEffect(() => {
if (allowedRepos.length === 1) {
if (allowedRepos.length > 0) {
setState(prevState => ({ ...prevState, repoName: allowedRepos[0] }));
}
}, [setState, allowedRepos]);
@@ -36,6 +36,8 @@ jest.useFakeTimers();
const testFactRetriever: FactRetriever = {
id: 'test_factretriever',
version: '0.0.1',
title: 'Test 1',
description: 'testing',
entityFilter: [{ kind: 'component' }],
schema: {
testnumberfact: {
@@ -30,6 +30,9 @@ import isEmpty from 'lodash/isEmpty';
export const entityMetadataFactRetriever: FactRetriever = {
id: 'entityMetadataFactRetriever',
version: '0.0.1',
title: 'Entity Metadata',
description:
'Generates facts which indicate the completeness of entity metadata',
schema: {
hasTitle: {
type: 'boolean',
@@ -29,6 +29,9 @@ import { Entity } from '@backstage/catalog-model';
export const entityOwnershipFactRetriever: FactRetriever = {
id: 'entityOwnershipFactRetriever',
version: '0.0.1',
title: 'Entity Ownership',
description:
'Generates facts which indicate the quality of data in the spec.owner field',
entityFilter: [
{ kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] },
],
@@ -34,6 +34,9 @@ const techdocsAnnotationFactName =
export const techdocsFactRetriever: FactRetriever = {
id: 'techdocsFactRetriever',
version: '0.0.1',
title: 'Tech Docs',
description:
'Generates facts related to the completeness of techdocs configuration for entities',
schema: {
[techdocsAnnotationFactName]: {
type: 'boolean',
+2
View File
@@ -46,12 +46,14 @@ export type FactLifecycle = TTL | MaxItems;
// @public
export interface FactRetriever {
description?: string;
entityFilter?:
| Record<string, string | symbol | (string | symbol)[]>[]
| Record<string, string | symbol | (string | symbol)[]>;
handler: (ctx: FactRetrieverContext) => Promise<TechInsightFact[]>;
id: string;
schema: FactSchema;
title?: string;
version: string;
}
+10
View File
@@ -174,6 +174,16 @@ export interface FactRetriever {
*/
version: string;
/**
* A short display title for the fact retriever to be used in the interface
*/
title?: string;
/**
* A short display description for the fact retriever to be used in the interface.
*/
description?: string;
/**
* Handler function that needs to be implemented to retrieve fact values for entities.
*