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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -32,3 +32,4 @@ export type { CatalogModelExtensionPoint } from './extensions';
|
||||
export { catalogModelExtensionPoint } from './extensions';
|
||||
|
||||
export * from './scmEvents';
|
||||
export { provideStaticCatalogModel } from './provideStaticCatalogModel';
|
||||
|
||||
@@ -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 ?? []),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user