address review comments
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -2,4 +2,30 @@
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Implemented `catalogScmEventsServiceRef` event handling in the builtin entity providers.
|
||||
Implemented handling of events from the newly introduced alpha
|
||||
`catalogScmEventsServiceRef` service, in the builtin entity providers. This
|
||||
allows entities to get refreshed, and locations updated or removed, as a
|
||||
response to incoming events. In its first iteration, only the GitHub module
|
||||
implements such event handling however.
|
||||
|
||||
This is not yet enabled by default, but this fact may change in a future
|
||||
release. To try it out, ensure that you have the latest catalog GitHub module
|
||||
installed, and set the following in your app-config:
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
scmEvents: true
|
||||
```
|
||||
|
||||
Or if you want to pick and choose from the various features:
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
scmEvents:
|
||||
# refresh (reprocess) upon events?
|
||||
refresh: true
|
||||
# automatically unregister locations based on events? (files deleted, repos archived, etc)
|
||||
unregister: true
|
||||
# automatically move locations based on events? (repo transferred, file renamed, etc)
|
||||
move: true
|
||||
```
|
||||
|
||||
Vendored
+43
@@ -257,5 +257,48 @@ export interface Config {
|
||||
priority?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings that control what to do when receiving messages from the SCM
|
||||
* events service.
|
||||
*
|
||||
* @defaultValue false
|
||||
* @remarks
|
||||
*
|
||||
* This is primarily meant to affect builtin providers in the catalog
|
||||
* backend such as the location handler, but other providers and processors
|
||||
* may also read this configuration.
|
||||
*
|
||||
* If set to false, disable all handling of SCM events.
|
||||
*
|
||||
* If set to true, enable all default handling of SCM events. Note that the
|
||||
* set of default handling can change over time.
|
||||
*
|
||||
* You can also configure individual handlers one by one.
|
||||
*/
|
||||
scmEvents?:
|
||||
| boolean
|
||||
| {
|
||||
/**
|
||||
* Trigger refreshes (reprocessing) of entities that are affected by an
|
||||
* SCM event. This may include source control file content changes,
|
||||
* repository status changes, etc.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
refresh?: boolean;
|
||||
/**
|
||||
* Unregister entities that are deleted as a result of an SCM event.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
unregister?: boolean;
|
||||
/**
|
||||
* Move entities that are moved as a result of an SCM event.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
move?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ describe('DefaultLocationStore', () => {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
const connection = { applyMutation: jest.fn(), refresh: jest.fn() };
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents);
|
||||
const store = new DefaultLocationStore(knex, mockScmEvents, {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
await store.connect(connection);
|
||||
return { store, connection, knex };
|
||||
}
|
||||
|
||||
@@ -42,15 +42,22 @@ import {
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import { chunk, uniqBy } from 'lodash';
|
||||
import parseGitUrl, { type GitUrl } from 'git-url-parse';
|
||||
import { ScmEventHandlingConfig } from '../util/readScmEventHandlingConfig';
|
||||
|
||||
export class DefaultLocationStore implements LocationStore, EntityProvider {
|
||||
private _connection: EntityProviderConnection | undefined;
|
||||
private readonly db: Knex;
|
||||
private readonly scmEvents: CatalogScmEventsService;
|
||||
private readonly scmEventHandlingConfig: ScmEventHandlingConfig;
|
||||
|
||||
constructor(db: Knex, scmEvents: CatalogScmEventsService) {
|
||||
constructor(
|
||||
db: Knex,
|
||||
scmEvents: CatalogScmEventsService,
|
||||
scmEventHandlingConfig: ScmEventHandlingConfig,
|
||||
) {
|
||||
this.db = db;
|
||||
this.scmEvents = scmEvents;
|
||||
this.scmEventHandlingConfig = scmEventHandlingConfig;
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
@@ -194,7 +201,12 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
|
||||
entities,
|
||||
});
|
||||
|
||||
this.scmEvents.subscribe({ onEvents: this.#onScmEvents.bind(this) });
|
||||
if (
|
||||
this.scmEventHandlingConfig.unregister ||
|
||||
this.scmEventHandlingConfig.move
|
||||
) {
|
||||
this.scmEvents.subscribe({ onEvents: this.#onScmEvents.bind(this) });
|
||||
}
|
||||
}
|
||||
|
||||
private async locations(dbOrTx: Knex.Transaction | Knex = this.db) {
|
||||
@@ -221,16 +233,28 @@ export class DefaultLocationStore implements LocationStore, EntityProvider {
|
||||
const locationPrefixesToMove = new Map<string, string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'location.deleted') {
|
||||
if (
|
||||
event.type === 'location.deleted' &&
|
||||
this.scmEventHandlingConfig.unregister
|
||||
) {
|
||||
exactLocationsToDelete.add(event.url);
|
||||
} else if (event.type === 'location.moved') {
|
||||
} else if (
|
||||
event.type === 'location.moved' &&
|
||||
this.scmEventHandlingConfig.move
|
||||
) {
|
||||
// Since Location entities are named after their target URL, these
|
||||
// unfortunately have to be translated into deletion and creation
|
||||
exactLocationsToDelete.add(event.fromUrl);
|
||||
exactLocationsToCreate.add(event.toUrl);
|
||||
} else if (event.type === 'repository.deleted') {
|
||||
} else if (
|
||||
event.type === 'repository.deleted' &&
|
||||
this.scmEventHandlingConfig.unregister
|
||||
) {
|
||||
locationPrefixesToDelete.add(event.url);
|
||||
} else if (event.type === 'repository.moved') {
|
||||
} else if (
|
||||
event.type === 'repository.moved' &&
|
||||
this.scmEventHandlingConfig.move
|
||||
) {
|
||||
// These also have to be handled with deletions and creations
|
||||
locationPrefixesToMove.set(event.fromUrl, event.toUrl);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@ describe('GenericScmEventRefreshProvider', () => {
|
||||
publish: jest.fn(),
|
||||
};
|
||||
|
||||
const store = new GenericScmEventRefreshProvider(knex, scmEvents);
|
||||
const store = new GenericScmEventRefreshProvider(knex, scmEvents, {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
|
||||
const connection = {
|
||||
applyMutation: jest.fn(),
|
||||
|
||||
@@ -33,21 +33,28 @@ import {
|
||||
DbRefreshStateRow,
|
||||
DbSearchRow,
|
||||
} from '../database/tables';
|
||||
import { ScmEventHandlingConfig } from '../util/readScmEventHandlingConfig';
|
||||
|
||||
/**
|
||||
* Deals in a generic fashion with SCM events, refreshing entities as needed.
|
||||
*
|
||||
* It's implemented in the form of an entity provider even though itt actually
|
||||
* It's implemented in the form of an entity provider even though its actually
|
||||
* does not behave like one, mostly in order to consistently start treating
|
||||
* events on connect time when the engine is known to be ready.
|
||||
*/
|
||||
export class GenericScmEventRefreshProvider implements EntityProvider {
|
||||
readonly #knex: Knex;
|
||||
readonly #scmEvents: CatalogScmEventsService;
|
||||
readonly #scmEventHandlingConfig: ScmEventHandlingConfig;
|
||||
|
||||
constructor(knex: Knex, scmEvents: CatalogScmEventsService) {
|
||||
constructor(
|
||||
knex: Knex,
|
||||
scmEvents: CatalogScmEventsService,
|
||||
scmEventHandlingConfig: ScmEventHandlingConfig,
|
||||
) {
|
||||
this.#knex = knex;
|
||||
this.#scmEvents = scmEvents;
|
||||
this.#scmEventHandlingConfig = scmEventHandlingConfig;
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
@@ -55,7 +62,9 @@ export class GenericScmEventRefreshProvider implements EntityProvider {
|
||||
}
|
||||
|
||||
async connect(_connection: EntityProviderConnection): Promise<void> {
|
||||
this.#scmEvents.subscribe({ onEvents: this.#onScmEvents.bind(this) });
|
||||
if (this.#scmEventHandlingConfig.refresh) {
|
||||
this.#scmEvents.subscribe({ onEvents: this.#onScmEvents.bind(this) });
|
||||
}
|
||||
}
|
||||
|
||||
async #onScmEvents(events: CatalogScmEvent[]): Promise<void> {
|
||||
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import { filterAndSortProcessors, filterProviders } from './util';
|
||||
import { GenericScmEventRefreshProvider } from '../providers/GenericScmEventRefreshProvider';
|
||||
import { readScmEventHandlingConfig } from '../util/readScmEventHandlingConfig';
|
||||
|
||||
export type CatalogEnvironment = {
|
||||
logger: LoggerService;
|
||||
@@ -532,11 +533,17 @@ export class CatalogBuilder {
|
||||
});
|
||||
}
|
||||
|
||||
const locationStore = new DefaultLocationStore(dbClient, catalogScmEvents);
|
||||
const scmEventHandlingConfig = readScmEventHandlingConfig(config);
|
||||
const locationStore = new DefaultLocationStore(
|
||||
dbClient,
|
||||
catalogScmEvents,
|
||||
scmEventHandlingConfig,
|
||||
);
|
||||
const configLocationProvider = new ConfigLocationEntityProvider(config);
|
||||
const scmEvents = new GenericScmEventRefreshProvider(
|
||||
dbClient,
|
||||
catalogScmEvents,
|
||||
scmEventHandlingConfig,
|
||||
);
|
||||
|
||||
const entityProviderEntries = lodash.uniqBy(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { readScmEventHandlingConfig } from './readScmEventHandlingConfig';
|
||||
|
||||
describe('readScmEventHandlingConfig', () => {
|
||||
it('returns defaults when catalog.scmEvents is not set', () => {
|
||||
const config = new ConfigReader({});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns defaults when catalog.scmEvents is true', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
scmEvents: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns all disabled when catalog.scmEvents is false', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
scmEvents: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: false,
|
||||
unregister: false,
|
||||
move: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges partial config with defaults', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
scmEvents: {
|
||||
refresh: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: false,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows disabling individual options', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
scmEvents: {
|
||||
refresh: true,
|
||||
unregister: false,
|
||||
move: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: true,
|
||||
unregister: false,
|
||||
move: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows enabling all options explicitly', () => {
|
||||
const config = new ConfigReader({
|
||||
catalog: {
|
||||
scmEvents: {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readScmEventHandlingConfig(config)).toEqual({
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { Config } from '@backstage/config';
|
||||
import lodash from 'lodash';
|
||||
|
||||
export interface ScmEventHandlingConfig {
|
||||
refresh: boolean;
|
||||
unregister: boolean;
|
||||
move: boolean;
|
||||
}
|
||||
|
||||
const disabled: ScmEventHandlingConfig = {
|
||||
refresh: false,
|
||||
unregister: false,
|
||||
move: false,
|
||||
};
|
||||
|
||||
const defaults: ScmEventHandlingConfig = {
|
||||
refresh: true,
|
||||
unregister: true,
|
||||
move: true,
|
||||
};
|
||||
|
||||
export function readScmEventHandlingConfig(
|
||||
config: Config,
|
||||
): ScmEventHandlingConfig {
|
||||
const rootKey = 'catalog.scmEvents';
|
||||
|
||||
if (!config.has(rootKey) || config.get(rootKey) === true) {
|
||||
return { ...defaults };
|
||||
} else if (config.get(rootKey) === false) {
|
||||
return { ...disabled };
|
||||
}
|
||||
|
||||
const given = {
|
||||
refresh: config.getOptionalBoolean(`${rootKey}.refresh`),
|
||||
unregister: config.getOptionalBoolean(`${rootKey}.unregister`),
|
||||
move: config.getOptionalBoolean(`${rootKey}.move`),
|
||||
};
|
||||
|
||||
return lodash.merge({}, defaults, given);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export interface CatalogScmEventsService {
|
||||
// @alpha
|
||||
export const catalogScmEventsServiceRef: ServiceRef<
|
||||
CatalogScmEventsService,
|
||||
'root',
|
||||
'plugin',
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
|
||||
@@ -33,14 +33,16 @@ import { DefaultCatalogScmEventsService } from './DefaultCatalogScmEventsService
|
||||
*/
|
||||
export const catalogScmEventsServiceRef =
|
||||
createServiceRef<CatalogScmEventsService>({
|
||||
id: 'catalog-scm-events',
|
||||
scope: 'root',
|
||||
id: 'catalog.scm-events.alpha',
|
||||
defaultFactory: async service =>
|
||||
createServiceFactory({
|
||||
service,
|
||||
deps: {},
|
||||
factory() {
|
||||
createRootContext() {
|
||||
return new DefaultCatalogScmEventsService();
|
||||
},
|
||||
factory(_, ctx) {
|
||||
return ctx;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user