Add catalog model layers with JSON Schema based kind declarations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-03-29 22:16:59 +02:00
parent 774e641e45
commit e5fcfcb2cb
130 changed files with 10224 additions and 30 deletions
+5
View File
@@ -15,7 +15,12 @@
*/
import { createBackend } from '@backstage/backend-defaults';
import { provideStaticCatalogModel } from '@backstage/plugin-catalog-node/alpha';
import { templateModelLayer } from '@backstage/plugin-scaffolder-common/alpha';
const backend = createBackend();
backend.add(import('../src'));
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));
backend.add(provideStaticCatalogModel({ layers: [templateModelLayer] }));
backend.start();
+4
View File
@@ -79,6 +79,8 @@
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
"@opentelemetry/api": "^1.9.0",
"ajv": "^8.10.0",
"ajv-errors": "^3.0.0",
"codeowners-utils": "^1.0.2",
"core-js": "^3.6.5",
"express": "^4.22.0",
@@ -102,7 +104,9 @@
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-catalog-backend-module-logs": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/repo-tools": "workspace:^",
"@types/core-js": "^2.5.4",
"@types/express": "^4.17.6",
@@ -0,0 +1,87 @@
/*
* Copyright 2026 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 { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
import {
CatalogModel,
CatalogModelSource,
compileCatalogModel,
} from '@backstage/catalog-model/alpha';
/**
* Wraps the concern of maintaining a compiled catalog model based on sources.
*
* @internal
*/
export class ModelHolder {
#model: CatalogModel;
static modelPassthroughForTest(model: CatalogModel): ModelHolder {
return new ModelHolder(model);
}
static async create(options: {
sources: CatalogModelSource[];
logger: LoggerService;
lifecycle: LifecycleService;
}): Promise<ModelHolder> {
const { sources, logger, lifecycle } = options;
const shutdownController = new AbortController();
lifecycle.addShutdownHook(() => shutdownController.abort());
logger.info(`Reading ${sources.length} catalog model sources`);
let readyCount = 0;
const logInterval = setInterval(() => {
const remaining = sources.length - readyCount;
logger.warn(
`Waiting for ${remaining}/${sources.length} catalog model sources to be ready`,
);
}, 3000);
// TODO(freben): Obviopusly this needs to be extended to support dynamic
// model source events during the lifetime of the plugin.
try {
const layers = await Promise.all(
sources.map(source =>
source
.read({ signal: shutdownController.signal })
.next()
.then(result => {
readyCount += 1;
const ls = result.value?.layers ?? [];
for (const layer of ls) {
logger.info(`Loaded catalog model layer: ${layer.layerId}`);
}
return ls;
}),
),
);
return new ModelHolder(compileCatalogModel(layers.flat()));
} finally {
clearInterval(logInterval);
}
}
get model(): CatalogModel {
return this.#model;
}
private constructor(model: CatalogModel) {
this.#model = model;
}
}
@@ -0,0 +1,487 @@
/*
* Copyright 2026 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 } from '@backstage/catalog-model';
import {
CatalogModel,
CatalogModelKind,
CatalogModelRelation,
} from '@backstage/catalog-model/alpha';
import { ModelProcessor } from './ModelProcessor';
import { ModelHolder } from '../model/ModelHolder';
const componentKind: CatalogModelKind = {
apiVersions: ['backstage.io/v1alpha1'],
names: { kind: 'Component', singular: 'component', plural: 'components' },
relationFields: [
{
path: 'spec.owner',
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit',
allowedKinds: ['Group', 'User'],
},
{
path: 'spec.dependsOn',
relation: 'dependsOn',
defaultKind: 'Component',
defaultNamespace: 'default',
allowedKinds: ['Component', 'Resource'],
},
],
jsonSchema: {
type: 'object',
required: ['spec'],
properties: {
spec: {
type: 'object',
required: ['type', 'lifecycle', 'owner'],
properties: {
type: { type: 'string', minLength: 1 },
lifecycle: { type: 'string', minLength: 1 },
owner: { type: 'string', minLength: 1 },
},
},
},
},
};
const ownedByRelation: CatalogModelRelation = {
fromKind: ['Component'],
toKind: ['Group', 'User'],
description: 'Ownership',
forward: { type: 'ownedBy', title: 'owned by' },
reverse: { type: 'ownerOf', title: 'owner of' },
};
const dependsOnRelation: CatalogModelRelation = {
fromKind: ['Component'],
toKind: ['Component', 'Resource'],
description: 'Dependency',
forward: { type: 'dependsOn', title: 'depends on' },
reverse: { type: 'dependencyOf', title: 'dependency of' },
};
function createModel(overrides?: {
getKind?: CatalogModel['getKind'];
getRelations?: CatalogModel['getRelations'];
}): ModelHolder {
return ModelHolder.modelPassthroughForTest({
getKind: overrides?.getKind ?? (() => componentKind),
getRelations:
overrides?.getRelations ?? (() => [ownedByRelation, dependsOnRelation]),
});
}
function createEntity(spec?: Entity['spec']): Entity {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'my-component', namespace: 'default' },
spec: spec ?? {
type: 'service',
lifecycle: 'production',
owner: 'group:default/my-team',
},
};
}
const location = { type: 'url', target: 'https://example.com' };
describe('ModelProcessor', () => {
describe('preProcessEntity', () => {
it('returns the entity unchanged when the kind is not found', async () => {
const processor = new ModelProcessor(
createModel({ getKind: () => undefined }),
);
const entity = createEntity();
const result = await processor.preProcessEntity(entity);
expect(result).toBe(entity);
});
it('sorts array relation fields in-place', async () => {
const processor = new ModelProcessor(createModel());
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'group:default/my-team',
dependsOn: ['component:c', 'component:a', 'component:b'],
});
const result = await processor.preProcessEntity(entity);
expect((result.spec as any).dependsOn).toEqual([
'component:a',
'component:b',
'component:c',
]);
});
it('does not modify scalar relation fields', async () => {
const processor = new ModelProcessor(createModel());
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'group:default/my-team',
});
const result = await processor.preProcessEntity(entity);
expect((result.spec as any).owner).toBe('group:default/my-team');
});
it('handles entities with no spec gracefully', async () => {
const processor = new ModelProcessor(createModel());
const entity = createEntity();
delete (entity as any).spec;
const result = await processor.preProcessEntity(entity);
expect(result).toBe(entity);
});
});
describe('validateEntityKind', () => {
it('returns false when the kind is not found', async () => {
const processor = new ModelProcessor(
createModel({ getKind: () => undefined }),
);
const entity = createEntity();
expect(await processor.validateEntityKind(entity)).toBe(false);
});
it('returns true when the entity is valid', async () => {
const processor = new ModelProcessor(createModel());
const entity = createEntity();
expect(await processor.validateEntityKind(entity)).toBe(true);
});
it('throws when the entity fails schema validation', async () => {
const processor = new ModelProcessor(createModel());
const entity = createEntity({
type: 'service',
lifecycle: 'production',
// missing required "owner"
});
await expect(processor.validateEntityKind(entity)).rejects.toThrow(
/Validation of Component entity failed/,
);
});
});
describe('postProcessEntity', () => {
it('returns the entity unchanged when the kind is not found', async () => {
const processor = new ModelProcessor(
createModel({ getKind: () => undefined }),
);
const emit = jest.fn();
const entity = createEntity();
const result = await processor.postProcessEntity(entity, location, emit);
expect(result).toBe(entity);
expect(emit).not.toHaveBeenCalled();
});
it('emits only forward relations when relation declarations are not found', async () => {
const processor = new ModelProcessor(
createModel({ getRelations: () => undefined }),
);
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'my-team',
});
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: expect.objectContaining({ type: 'ownedBy' }),
}),
);
const reverseEmits = emit.mock.calls.filter(
([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownerOf',
);
expect(reverseEmits).toHaveLength(0);
});
it('emits forward and reverse relations for a scalar relation field', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'my-team',
});
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: {
source: {
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'my-team' },
},
}),
);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'my-team' },
type: 'ownerOf',
target: {
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
},
}),
);
});
it('emits forward and reverse relations for each element in an array relation field', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'my-team',
dependsOn: [
'component:default/service-a',
'component:default/service-b',
],
});
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: {
source: {
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
type: 'dependsOn',
target: {
kind: 'component',
namespace: 'default',
name: 'service-a',
},
},
}),
);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: {
source: {
kind: 'component',
namespace: 'default',
name: 'service-a',
},
type: 'dependencyOf',
target: {
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
},
}),
);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: {
source: {
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
type: 'dependsOn',
target: {
kind: 'component',
namespace: 'default',
name: 'service-b',
},
},
}),
);
});
it('skips both forward and reverse when target kind is not in allowedKinds', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'api:default/some-api',
});
await processor.postProcessEntity(entity, location, emit);
const ownedByEmits = emit.mock.calls.filter(
([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownedBy',
);
expect(ownedByEmits).toHaveLength(0);
const ownerOfEmits = emit.mock.calls.filter(
([r]: [any]) => r.type === 'relation' && r.relation.type === 'ownerOf',
);
expect(ownerOfEmits).toHaveLength(0);
});
it('allows any target kind when allowedKinds is not set', async () => {
const noAllowedKindsKind: CatalogModelKind = {
...componentKind,
relationFields: [
{
path: 'spec.owner',
relation: 'ownedBy',
defaultKind: 'Group',
defaultNamespace: 'inherit',
},
],
};
const processor = new ModelProcessor(
createModel({ getKind: () => noAllowedKindsKind }),
);
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'api:default/some-api',
});
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: expect.objectContaining({ type: 'ownedBy' }),
}),
);
});
it('uses defaultNamespace inherit to pick the entity namespace', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'my-team',
});
entity.metadata.namespace = 'custom-ns';
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: expect.objectContaining({
type: 'ownedBy',
target: { kind: 'Group', namespace: 'custom-ns', name: 'my-team' },
}),
}),
);
});
it('uses default namespace when defaultNamespace is not inherit', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity({
type: 'service',
lifecycle: 'production',
owner: 'my-team',
dependsOn: ['service-a'],
});
entity.metadata.namespace = 'custom-ns';
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: expect.objectContaining({
type: 'dependsOn',
target: {
kind: 'Component',
namespace: 'default',
name: 'service-a',
},
}),
}),
);
});
it('handles entities with no spec gracefully', async () => {
const processor = new ModelProcessor(createModel());
const emit = jest.fn();
const entity = createEntity();
delete (entity as any).spec;
await processor.postProcessEntity(entity, location, emit);
expect(emit).not.toHaveBeenCalled();
});
it('handles nested relation fields via dot paths', async () => {
const nestedKind: CatalogModelKind = {
...componentKind,
relationFields: [
{
path: 'spec.nested.maintainer',
relation: 'ownedBy',
defaultKind: 'User',
defaultNamespace: 'default',
},
],
};
const processor = new ModelProcessor(
createModel({ getKind: () => nestedKind }),
);
const emit = jest.fn();
const entity = createEntity({
nested: { maintainer: 'jane' },
});
await processor.postProcessEntity(entity, location, emit);
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'relation',
relation: expect.objectContaining({
type: 'ownedBy',
target: { kind: 'User', namespace: 'default', name: 'jane' },
}),
}),
);
});
});
});
@@ -0,0 +1,169 @@
/*
* Copyright 2026 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 {
DEFAULT_NAMESPACE,
Entity,
getCompoundEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import {
CatalogProcessor,
CatalogProcessorEmit,
processingResult,
} from '@backstage/plugin-catalog-node';
import lodash from 'lodash';
import { ModelHolder } from '../model/ModelHolder';
import { SchemaValidator } from './SchemaValidator';
export class ModelProcessor implements CatalogProcessor {
readonly #modelHolder: ModelHolder;
readonly #schemaValidator = new SchemaValidator();
constructor(modelHolder: ModelHolder) {
this.#modelHolder = modelHolder;
}
getProcessorName(): string {
return 'ModelProcessor';
}
/**
* For all fields in the entity that the model says are relations: if it's an
* array of strings, sort that array. Since relations are unordered, this cuts
* down on unnecessary processing and stitching for sources that don't have a
* stable order for its output.
*/
async preProcessEntity(entity: Entity): Promise<Entity> {
const kind = this.#modelHolder.model.getKind(entity);
if (kind) {
for (const fieldModel of kind.relationFields) {
const value = lodash.get(entity, fieldModel.path);
if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
value.sort();
}
}
}
return entity;
}
/**
* If the model knows how to handle this entity, validate it against its
* schema and then return true. Otherwise return false.
*/
async validateEntityKind(entity: Entity): Promise<boolean> {
const kind = this.#modelHolder.model.getKind(entity);
if (!kind) {
return false;
}
const errors = this.#schemaValidator.validate(kind.jsonSchema, entity);
if (errors.length) {
throw new TypeError(
`Validation of ${entity.kind} entity failed: ${errors.join('; ')}`,
);
}
return true;
}
/**
* For all fields in the entity that the model says are relations: if the
* field is a string or an array of strings, emit both the forward and reverse
* relations that the model says apply for it.
*/
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
const kind = this.#modelHolder.model.getKind(entity);
if (!kind) {
return entity;
}
const modelRelations =
this.#modelHolder.model.getRelations({ kind: entity.kind }) ?? [];
const selfRef = getCompoundEntityRef(entity);
const selfNamespace = entity.metadata.namespace ?? DEFAULT_NAMESPACE;
for (const fieldModel of kind.relationFields) {
const fieldValue = lodash.get(entity, fieldModel.path);
if (!fieldValue) {
continue;
}
const shorthandRefs = (
Array.isArray(fieldValue) ? fieldValue : [fieldValue]
).filter((x): x is string => x && typeof x === 'string');
for (const shorthandRef of shorthandRefs) {
const targetRef = parseEntityRef(shorthandRef, {
defaultKind: fieldModel.defaultKind,
defaultNamespace:
fieldModel.defaultNamespace === 'inherit'
? selfNamespace
: DEFAULT_NAMESPACE,
});
const targetKind = targetRef.kind.toLocaleLowerCase('en-US');
if (
fieldModel.allowedKinds &&
!fieldModel.allowedKinds.some(
k => k.toLocaleLowerCase('en-US') === targetKind,
)
) {
// TODO: Make this more visible. We should probably not use logging,
// but if we added admonition support on entities, this would be a
// good time to emit one.
continue;
}
// Emit the forward relation
emit(
processingResult.relation({
source: selfRef,
type: fieldModel.relation,
target: targetRef,
}),
);
// Emit the reverse relation if the model knows about it
const selfKind = entity.kind.toLocaleLowerCase('en-US');
const relation = modelRelations.find(
r =>
r.forward.type === fieldModel.relation &&
r.fromKind.some(k => k.toLocaleLowerCase('en-US') === selfKind) &&
r.toKind.some(k => k.toLocaleLowerCase('en-US') === targetKind),
);
if (relation) {
emit(
processingResult.relation({
source: targetRef,
type: relation.reverse.type,
target: selfRef,
}),
);
}
}
}
return entity;
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2026 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 { SchemaValidator } from './SchemaValidator';
const schema = {
type: 'object',
required: ['spec'],
properties: {
spec: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string', minLength: 1 },
count: { type: 'number' },
},
},
},
};
describe('SchemaValidator', () => {
it('returns no errors for valid data', () => {
const validator = new SchemaValidator();
const errors = validator.validate(schema, {
spec: { name: 'foo' },
});
expect(errors).toEqual([]);
});
it('returns errors for missing required field', () => {
const validator = new SchemaValidator();
const errors = validator.validate(schema, { spec: {} });
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('name'))).toBe(true);
});
it('returns errors for wrong type', () => {
const validator = new SchemaValidator();
const errors = validator.validate(schema, {
spec: { name: 'foo', count: 'not-a-number' },
});
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('count') || e.includes('number'))).toBe(
true,
);
});
it('returns errors when spec is missing entirely', () => {
const validator = new SchemaValidator();
const errors = validator.validate(schema, {});
expect(errors.length).toBeGreaterThan(0);
});
it('caches compiled validators across calls', () => {
const validator = new SchemaValidator();
validator.validate(schema, { spec: { name: 'a' } });
validator.validate(schema, { spec: { name: 'b' } });
// No way to directly observe caching, but verifying that repeated
// calls with the same schema object work correctly
expect(validator.validate(schema, { spec: { name: 'c' } })).toEqual([]);
});
it('expires cached validators after the TTL', () => {
jest.useFakeTimers();
try {
const validator = new SchemaValidator({ ttlMs: 1000 });
// First call compiles and caches
expect(validator.validate(schema, { spec: { name: 'a' } })).toEqual([]);
// Advance past TTL
jest.advanceTimersByTime(1500);
// Should still work (recompiles from expired cache)
expect(validator.validate(schema, { spec: { name: 'b' } })).toEqual([]);
expect(validator.validate(schema, { spec: {} }).length).toBeGreaterThan(
0,
);
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,105 @@
/*
* Copyright 2026 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 { JsonObject } from '@backstage/types';
import Ajv, { ValidateFunction } from 'ajv';
import ajvErrors from 'ajv-errors';
class ExpiryMap<K, V> extends Map<K, V> {
readonly #ttlMs: number;
readonly #timestamps = new Map<K, number>();
constructor(ttlMs: number) {
super();
this.#ttlMs = ttlMs;
}
set(key: K, value: V) {
this.#timestamps.set(key, Date.now());
return super.set(key, value);
}
get(key: K) {
const timestamp = this.#timestamps.get(key);
if (timestamp !== undefined && Date.now() - timestamp > this.#ttlMs) {
this.delete(key);
return undefined;
}
return super.get(key);
}
delete(key: K) {
this.#timestamps.delete(key);
return super.delete(key);
}
clear() {
this.#timestamps.clear();
return super.clear();
}
}
/**
* A helper that lazily compiles and caches AJV validators for JSON schemas,
* with a time-based expiry to avoid holding on to stale entries indefinitely.
*/
export class SchemaValidator {
readonly #ajv: Ajv;
readonly #cache: ExpiryMap<JsonObject, ValidateFunction>;
constructor(options?: { ttlMs?: number }) {
this.#cache = new ExpiryMap(options?.ttlMs ?? 60 * 60 * 1000); // 1 hour
this.#ajv = new Ajv({
allowUnionTypes: true,
allErrors: true,
validateSchema: true,
});
ajvErrors(this.#ajv);
}
/**
* Validates the given data against the provided JSON schema. Returns an
* array of human-readable error strings, or an empty array if valid.
*/
validate(schema: JsonObject, data: unknown): string[] {
const validator = this.#getOrCompile(schema);
const valid = validator(data);
if (valid) {
return [];
}
return (validator.errors ?? []).map(
e =>
`${e.instancePath || '<root>'} ${e.message}${
e.params
? ` - ${Object.entries(e.params)
.map(([key, val]) => `${key}: ${val}`)
.join(', ')}`
: ''
}`,
);
}
#getOrCompile(schema: JsonObject): ValidateFunction {
const cached = this.#cache.get(schema);
if (cached) {
return cached;
}
const validator = this.#ajv.compile(schema);
this.#cache.set(schema, validator);
return validator;
}
}
@@ -106,9 +106,12 @@ import { filterAndSortProcessors, filterProviders } from './util';
import { GenericScmEventRefreshProvider } from '../providers/GenericScmEventRefreshProvider';
import { readScmEventHandlingConfig } from '../util/readScmEventHandlingConfig';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { ModelProcessor } from '../processors/ModelProcessor';
import { ModelHolder } from '../model/ModelHolder';
export type CatalogEnvironment = {
logger: LoggerService;
modelHolder?: ModelHolder;
database: DatabaseService;
config: RootConfigService;
reader: UrlReaderService;
@@ -629,9 +632,11 @@ export class CatalogBuilder {
];
const builtinKindsEntityProcessor = new BuiltinKindsEntityProcessor();
// If the user adds a processor named 'BuiltinKindsEntityProcessor',
// skip inclusion of the catalog-backend version.
// If the user adds a processor named 'BuiltinKindsEntityProcessor', skip
// inclusion of the catalog-backend version. Same if there's a model
// registered - then we are using the new model flow.
if (
!this.env.modelHolder &&
!this.processors.some(
processor =>
processor.getProcessorName() ===
@@ -640,6 +645,9 @@ export class CatalogBuilder {
) {
processors.push(builtinKindsEntityProcessor);
}
if (this.env.modelHolder) {
processors.push(new ModelProcessor(this.env.modelHolder));
}
const disableDefaultProcessors = config.getOptionalBoolean(
'catalog.disableDefaultProcessors',
@@ -13,13 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import {
actionsRegistryServiceRef,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { Entity, Validators } from '@backstage/catalog-model';
import { CatalogModelSource } from '@backstage/catalog-model/alpha';
import { ForwardedError } from '@backstage/errors';
import {
catalogAnalysisExtensionPoint,
CatalogLocationsExtensionPoint,
catalogLocationsExtensionPoint,
catalogProcessingExtensionPoint,
CatalogProcessor,
CatalogProcessorParser,
catalogServiceRef,
@@ -27,12 +37,6 @@ import {
PlaceholderResolver,
ScmLocationAnalyzer,
} from '@backstage/plugin-catalog-node';
import {
catalogAnalysisExtensionPoint,
CatalogLocationsExtensionPoint,
catalogLocationsExtensionPoint,
catalogProcessingExtensionPoint,
} from '@backstage/plugin-catalog-node';
import {
CatalogModelExtensionPoint,
catalogModelExtensionPoint,
@@ -40,13 +44,10 @@ import {
} from '@backstage/plugin-catalog-node/alpha';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { merge } from 'lodash';
import { CatalogBuilder } from './CatalogBuilder';
import {
actionsRegistryServiceRef,
metricsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { createCatalogActions } from '../actions';
import { ModelHolder } from '../model/ModelHolder';
import type { EntityProviderEntry } from '../processing/connectEntityProviders';
import { CatalogBuilder } from './CatalogBuilder';
class CatalogLocationsExtensionPointImpl
implements CatalogLocationsExtensionPoint
@@ -87,6 +88,16 @@ class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint {
get entityDataParser() {
return this.#entityDataParser;
}
#modelSources: CatalogModelSource[] = [];
addModelSource(source: CatalogModelSource): void {
this.#modelSources.push(source);
}
get modelSources() {
return this.#modelSources;
}
}
/**
@@ -206,8 +217,17 @@ export const catalogPlugin = createBackendPlugin({
catalogScmEvents,
metrics,
}) {
const modelHolder = modelExtensions.modelSources.length
? await ModelHolder.create({
sources: modelExtensions.modelSources,
logger,
lifecycle,
})
: undefined;
const builder = await CatalogBuilder.create({
config,
modelHolder,
reader,
permissions,
permissionsRegistry,
+6 -1
View File
@@ -69,13 +69,18 @@
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
"@opentelemetry/api": "^1.9.0",
"express": "^4.22.0",
"express-promise-router": "^4.1.0",
"lodash": "^4.17.21",
"yaml": "^2.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"msw": "^1.0.0"
"@types/express": "^4.17.6",
"@types/supertest": "^2.0.8",
"msw": "^1.0.0",
"supertest": "^7.0.0"
},
"peerDependencies": {
"@backstage/backend-test-utils": "workspace:^"
+9
View File
@@ -3,6 +3,9 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CatalogModelLayer } from '@backstage/catalog-model/alpha';
import { CatalogModelSource } from '@backstage/catalog-model/alpha';
import { CatalogProcessorParser } from '@backstage/plugin-catalog-node';
import { EntitiesSearchFilter } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';
@@ -21,6 +24,7 @@ export const catalogEntityPermissionResourceRef: PermissionResourceRef<
// @alpha (undocumented)
export interface CatalogModelExtensionPoint {
addModelSource(source: CatalogModelSource): void;
setEntityDataParser(parser: CatalogProcessorParser): void;
setFieldValidators(validators: Partial<Validators>): void;
}
@@ -99,5 +103,10 @@ export interface CatalogScmEventsServiceSubscriber {
onEvents: (events: CatalogScmEvent[]) => Promise<void>;
}
// @alpha
export function provideStaticCatalogModel(options?: {
layers?: CatalogModelLayer[];
}): BackendFeature;
// (No @packageDocumentation comment for this package)
```
+1
View File
@@ -32,3 +32,4 @@ export type { CatalogModelExtensionPoint } from './extensions';
export { catalogModelExtensionPoint } from './extensions';
export * from './scmEvents';
export { provideStaticCatalogModel } from './provideStaticCatalogModel';
+11 -2
View File
@@ -16,12 +16,13 @@
import { createExtensionPoint } from '@backstage/backend-plugin-api';
import { Entity, Validators } from '@backstage/catalog-model';
import { CatalogModelSource } from '@backstage/catalog-model/alpha';
import {
CatalogProcessor,
CatalogProcessorParser,
EntityProvider,
PlaceholderResolver,
LocationAnalyzer,
PlaceholderResolver,
ScmLocationAnalyzer,
} from '@backstage/plugin-catalog-node';
@@ -103,10 +104,18 @@ export interface CatalogModelExtensionPoint {
setFieldValidators(validators: Partial<Validators>): void;
/**
* Sets the entity data parser which is used to read raw data from locations
* Sets the entity data parser which is used to read raw data from locations.
*
* @param parser - Parser which will used to extract entities from raw data
*/
setEntityDataParser(parser: CatalogProcessorParser): void;
/**
* Adds a catalog model source to be part of the compiled entity model.
*
* @param source - The model source to add
*/
addModelSource(source: CatalogModelSource): void;
}
/**
@@ -0,0 +1,63 @@
/*
* Copyright 2026 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 { createBackendModule } from '@backstage/backend-plugin-api';
import {
CatalogModelLayer,
CatalogModelSources,
} from '@backstage/catalog-model/alpha';
import { catalogModelExtensionPoint } from './extensions';
/**
* Creates a backend module that registers static catalog model layers.
*
* @alpha
* @remarks
*
* This is a convenience function for registering catalog model layers
* without having to manually create a backend module and interact with
* the catalog model extension point. The built-in default catalog entity
* model is always included automatically.
*
* @example
* ```ts
* backend.add(
* provideStaticCatalogModel({
* layers: [templateModelLayer],
* }),
* );
* ```
*/
export function provideStaticCatalogModel(options?: {
layers?: CatalogModelLayer[];
}) {
return createBackendModule({
pluginId: 'catalog',
moduleId: 'static-catalog-model',
register(reg) {
reg.registerInit({
deps: {
model: catalogModelExtensionPoint,
},
async init({ model }) {
model.addModelSource(
CatalogModelSources.static(options?.layers ?? []),
);
},
});
},
});
}
+1 -1
View File
@@ -84,10 +84,10 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON';
+1 -1
View File
@@ -206,10 +206,10 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON';
+1 -1
View File
@@ -181,8 +181,8 @@ export const catalogTranslationRef: TranslationRef<
readonly 'relatedEntitiesCard.emptyHelpLinkTitle': 'Learn how to change this.';
readonly 'systemDiagramCard.title': 'System Diagram';
readonly 'systemDiagramCard.description': 'Use pinch & zoom to move around the diagram.';
readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on';
readonly 'systemDiagramCard.edgeLabels.partOf': 'part of';
readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on';
readonly 'systemDiagramCard.edgeLabels.provides': 'provides';
}
>;
+1 -1
View File
@@ -310,8 +310,8 @@ export const catalogTranslationRef: TranslationRef<
readonly 'relatedEntitiesCard.emptyHelpLinkTitle': 'Learn how to change this.';
readonly 'systemDiagramCard.title': 'System Diagram';
readonly 'systemDiagramCard.description': 'Use pinch & zoom to move around the diagram.';
readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on';
readonly 'systemDiagramCard.edgeLabels.partOf': 'part of';
readonly 'systemDiagramCard.edgeLabels.dependsOn': 'depends on';
readonly 'systemDiagramCard.edgeLabels.provides': 'provides';
}
>;
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2026 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 { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('../src'));
backend.start();
@@ -4,6 +4,7 @@
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
import { CatalogModelLayer } from '@backstage/catalog-model/alpha';
import { ResourcePermission } from '@backstage/plugin-permission-common';
// @alpha
@@ -50,6 +51,9 @@ export const taskReadPermission: ResourcePermission<'scaffolder-task'>;
// @alpha
export const templateManagementPermission: BasicPermission;
// @alpha
export const templateModelLayer: CatalogModelLayer;
// @alpha
export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>;
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './permissions';
export { templateModelLayer } from './catalogModel';
@@ -0,0 +1,56 @@
/*
* Copyright 2026 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 { createCatalogModelLayer } from '@backstage/catalog-model/alpha';
import schema from './Template.v1beta3.schema.json';
/**
* Extends the catalog model with the Template kind.
*
* @alpha
*/
export const templateModelLayer = createCatalogModelLayer({
layerId: 'Template',
builder: model => {
model.addKind({
group: 'scaffolder.backstage.io',
names: {
kind: 'Template',
singular: 'template',
plural: 'templates',
},
description: 'A template for scaffolding a new component',
versions: [
{
name: 'v1beta3',
relationFields: [
{
selector: { path: 'spec.owner' },
relation: 'ownedBy',
defaultKind: 'Group',
// TODO: This was inherit since before, but should ownership in general be default instead?
defaultNamespace: 'inherit',
allowedKinds: ['Group', 'User'],
},
],
schema: {
jsonSchema: schema as any,
},
},
],
});
},
});
+6 -6
View File
@@ -78,6 +78,7 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2<
autoSelect?: boolean | undefined;
defaultKind?: string | undefined;
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -96,7 +97,6 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;
@@ -108,6 +108,7 @@ export const EntityPickerFieldSchema: FieldSchema_2<
autoSelect?: boolean | undefined;
defaultKind?: string | undefined;
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -126,7 +127,6 @@ export const EntityPickerFieldSchema: FieldSchema_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;
@@ -255,6 +255,7 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2<
autoSelect?: boolean | undefined;
defaultKind?: string | undefined;
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -273,7 +274,6 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;
@@ -285,6 +285,7 @@ export const OwnedEntityPickerFieldSchema: FieldSchema_2<
autoSelect?: boolean | undefined;
defaultKind?: string | undefined;
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -303,7 +304,6 @@ export const OwnedEntityPickerFieldSchema: FieldSchema_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;
@@ -318,6 +318,7 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2<
string,
{
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -336,7 +337,6 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;
@@ -346,6 +346,7 @@ export const OwnerPickerFieldSchema: FieldSchema_2<
string,
{
defaultNamespace?: string | false | undefined;
allowedKinds?: string[] | undefined;
catalogFilter?:
| Record<
string,
@@ -364,7 +365,6 @@ export const OwnerPickerFieldSchema: FieldSchema_2<
}
>[]
| undefined;
allowedKinds?: string[] | undefined;
allowArbitraryValues?: boolean | undefined;
}
>;