update to just config reading-based

Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
aramissennyeydd
2025-11-09 18:46:03 -05:00
parent 979f9f6679
commit e3508b0e67
20 changed files with 250 additions and 436 deletions
+1 -2
View File
@@ -48,8 +48,7 @@
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"express-promise-router": "^4.1.0"
"@backstage/errors": "workspace:^"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
@@ -33,9 +33,6 @@ const baseFactories = [
mockServices.lifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.logger.factory(),
mockServices.rootConfig.factory(),
mockServices.rootHttpRouter.mock().factory,
mockServices.rootHealth.factory(),
];
function mkNoopFactory(ref: ServiceRef<{}, 'plugin'>) {
@@ -811,7 +808,7 @@ describe('BackendInitializer', () => {
});
it('should forward errors when modules fail to start', async () => {
const init = new BackendInitializer(baseFactories);
const init = new BackendInitializer([]);
init.add(testPlugin);
init.add(
createBackendModule({
@@ -833,7 +830,7 @@ describe('BackendInitializer', () => {
});
it('should reject duplicate plugins', async () => {
const init = new BackendInitializer(baseFactories);
const init = new BackendInitializer([]);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -862,7 +859,7 @@ describe('BackendInitializer', () => {
});
it('should reject duplicate modules', async () => {
const init = new BackendInitializer(baseFactories);
const init = new BackendInitializer([]);
init.add(testPlugin);
init.add(
createBackendModule({
@@ -899,9 +896,6 @@ describe('BackendInitializer', () => {
const init = new BackendInitializer([
mockServices.rootLifecycle.factory(),
mockServices.rootLogger.factory(),
mockServices.rootHttpRouter.mock().factory,
mockServices.rootHealth.factory(),
mockServices.rootConfig.factory(),
]);
init.add(testPlugin);
init.add(
-10
View File
@@ -1249,15 +1249,5 @@ export interface Config {
*/
plugins: string[];
}>;
/**
* A list of deployed Backstage instances that can be crawled for discovery.
*/
instances: Array<{
/**
* The base URL of the instance. All /.backstage/ routes should be accessible.
*/
baseUrl: string | { internal: string; external: string };
}>;
};
}
+4 -5
View File
@@ -33,11 +33,11 @@
"./permissions": "./src/entrypoints/permissions/index.ts",
"./rootConfig": "./src/entrypoints/rootConfig/index.ts",
"./rootHealth": "./src/entrypoints/rootHealth/index.ts",
"./rootSystemMetadata": "./src/entrypoints/rootSystemMetadata/index.ts",
"./rootHttpRouter": "./src/entrypoints/rootHttpRouter/index.ts",
"./rootLifecycle": "./src/entrypoints/rootLifecycle/index.ts",
"./rootLogger": "./src/entrypoints/rootLogger/index.ts",
"./scheduler": "./src/entrypoints/scheduler/index.ts",
"./systemMetadata": "./src/entrypoints/systemMetadata/index.ts",
"./urlReader": "./src/entrypoints/urlReader/index.ts",
"./userInfo": "./src/entrypoints/userInfo/index.ts",
"./alpha": "./src/alpha/index.ts",
@@ -92,15 +92,15 @@
"rootLifecycle": [
"src/entrypoints/rootLifecycle/index.ts"
],
"rootSystemMetadata": [
"src/entrypoints/rootSystemMetadata/index.ts"
],
"rootLogger": [
"src/entrypoints/rootLogger/index.ts"
],
"scheduler": [
"src/entrypoints/scheduler/index.ts"
],
"systemMetadata": [
"src/entrypoints/systemMetadata/index.ts"
],
"urlReader": [
"src/entrypoints/urlReader/index.ts"
],
@@ -198,7 +198,6 @@
"winston-transport": "^4.5.0",
"yauzl": "^3.0.0",
"yn": "^4.0.0",
"zen-observable": "^0.10.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.20.4"
},
@@ -5,6 +5,7 @@
```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { InstanceMetadataService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
@@ -21,5 +22,12 @@ export const actionsServiceFactory: ServiceFactory<
'singleton'
>;
// @alpha @deprecated (undocumented)
export const instanceMetadataServiceFactory: ServiceFactory<
InstanceMetadataService,
'plugin',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -150,6 +150,11 @@ export class HostDiscovery implements DiscoveryService {
throw new Error('Not initialized');
};
#resolutions: Map<
string,
Set<{ hash: string; target: { internal?: string; external?: string } }>
> = new Map();
static fromConfig(config: RootConfigService, options?: HostDiscoveryOptions) {
const discovery = new HostDiscovery(new SrvResolvers());
@@ -193,12 +198,23 @@ export class HostDiscovery implements DiscoveryService {
return await resolver(pluginId);
}
#updateResolvers(config: Config, defaultEndpoints?: HostDiscoveryEndpoint[]) {
this.#updateFallbackResolvers(config);
this.#updatePluginResolvers(config, defaultEndpoints);
async listResolutions() {
const _targets: Map<string, { internal?: string; external?: string }[]> =
new Map();
for (const [pluginId, targets] of this.#resolutions.entries()) {
const currentTargets = [...targets.values()].map(({ target }) => ({
...target,
}));
if (_targets.has(pluginId)) {
_targets.set(pluginId, [..._targets.get(pluginId)!, ...currentTargets]);
} else {
_targets.set(pluginId, currentTargets);
}
}
return _targets;
}
#updateFallbackResolvers(config: Config) {
getInstanceAddress(config: Config) {
const backendBaseUrl = trimEnd(config.getString('backend.baseUrl'), '/');
const {
@@ -220,12 +236,26 @@ export class HostDiscovery implements DiscoveryService {
host = `[${host}]`;
}
return {
internal: `${protocol}://${host}:${listenPort}`,
external: backendBaseUrl,
};
}
#updateResolvers(config: Config, defaultEndpoints?: HostDiscoveryEndpoint[]) {
this.#updateFallbackResolvers(config);
this.#updatePluginResolvers(config, defaultEndpoints);
}
#updateFallbackResolvers(config: Config) {
const { internal, external } = this.getInstanceAddress(config);
this.#internalFallbackResolver = this.#makeResolver(
`${protocol}://${host}:${listenPort}/api/{{pluginId}}`,
`${internal}/api/{{pluginId}}`,
false,
);
this.#externalFallbackResolver = this.#makeResolver(
`${backendBaseUrl}/api/{{pluginId}}`,
`${external}/api/{{pluginId}}`,
false,
);
}
@@ -264,6 +294,7 @@ export class HostDiscovery implements DiscoveryService {
for (const { target, plugins } of endpoints) {
let internalResolver: Resolver | undefined;
let externalResolver: Resolver | undefined;
this.#addResolution(target, plugins);
if (typeof target === 'string') {
internalResolver = externalResolver = this.#makeResolver(target, false);
@@ -293,6 +324,26 @@ export class HostDiscovery implements DiscoveryService {
this.#externalResolvers = externalResolvers;
}
#addResolution(
target: string | { internal?: string; external?: string },
plugins: string[],
) {
for (const pluginId of plugins) {
if (!this.#resolutions.has(pluginId)) {
this.#resolutions.set(pluginId, new Set());
}
const standardizedTarget =
typeof target === 'string'
? { external: target, internal: target }
: target;
const matchingResolution = this.#resolutions.get(pluginId)!;
const hash = JSON.stringify(standardizedTarget);
if (![...matchingResolution.values()].some(e => e.hash === hash)) {
matchingResolution.add({ target: standardizedTarget, hash });
}
}
}
#makeResolver(urlPattern: string, allowSrv: boolean): Resolver {
const withPluginId = (pluginId: string, url: string) => {
return url.replace(
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Backend, createSpecializedBackend } from '@backstage/backend-app-api';
import { systemMetadataServiceFactory } from './systemMetadataServiceFactory';
import { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory';
import { mockServices } from '@backstage/backend-test-utils';
import getPort from 'get-port';
import {
@@ -66,7 +66,7 @@ describe('SystemMetadataService', () => {
instance1 = createSpecializedBackend({
defaultServiceFactories: [
...baseFactories,
systemMetadataServiceFactory,
rootSystemMetadataServiceFactory,
configFactory(instance1HttpPort),
],
});
@@ -74,7 +74,7 @@ describe('SystemMetadataService', () => {
instance2 = createSpecializedBackend({
defaultServiceFactories: [
...baseFactories,
systemMetadataServiceFactory,
rootSystemMetadataServiceFactory,
configFactory(instance2HttpPort),
],
});
@@ -169,7 +169,7 @@ describe('SystemMetadataService', () => {
instance = createSpecializedBackend({
defaultServiceFactories: [
...baseFactories,
systemMetadataServiceFactory,
rootSystemMetadataServiceFactory,
mockServices.rootConfig.factory({
data: {
backend: {
@@ -236,7 +236,7 @@ describe('SystemMetadataService', () => {
instance = createSpecializedBackend({
defaultServiceFactories: [
...baseFactories,
systemMetadataServiceFactory,
rootSystemMetadataServiceFactory,
configFactory,
],
});
@@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { systemMetadataServiceFactory } from './systemMetadataServiceFactory';
export { DefaultSystemMetadataService } from './lib/DefaultSystemMetadataService';
export { rootSystemMetadataServiceFactory } from './rootSystemMetadataServiceFactory';
export { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService';
@@ -0,0 +1,96 @@
/*
* Copyright 2024 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 {
LoggerService,
RootConfigService,
RootInstanceMetadataService,
RootSystemMetadataService,
RootSystemMetadataServicePluginInfo,
} from '@backstage/backend-plugin-api';
import { HostDiscovery } from '../../discovery';
import {} from '@backstage/backend-plugin-api';
/**
* @alpha
*/
export class DefaultRootSystemMetadataService
implements RootSystemMetadataService
{
#hostDiscovery: HostDiscovery;
#instanceMetadata: RootInstanceMetadataService;
#config: RootConfigService;
constructor(options: {
logger: LoggerService;
config: RootConfigService;
instanceMetadata: RootInstanceMetadataService;
}) {
this.#hostDiscovery = HostDiscovery.fromConfig(options.config, {
logger: options.logger,
});
options.config.subscribe?.(() => {
this.#hostDiscovery = HostDiscovery.fromConfig(options.config, {
logger: options.logger,
});
});
this.#instanceMetadata = options.instanceMetadata;
this.#config = options.config;
}
public static create(pluginEnv: {
logger: LoggerService;
config: RootConfigService;
instanceMetadata: RootInstanceMetadataService;
}) {
return new DefaultRootSystemMetadataService(pluginEnv);
}
public async getInstalledPlugins(): Promise<
RootSystemMetadataServicePluginInfo[]
> {
const resolutions = await this.#hostDiscovery.listResolutions();
const instanceAddress = this.#hostDiscovery.getInstanceAddress(
this.#config,
);
const currentInstance = await this.#instanceMetadata.getInstalledPlugins();
for (const plugin of currentInstance) {
if (!resolutions.has(plugin.pluginId)) {
resolutions.set(plugin.pluginId, []);
}
resolutions.get(plugin.pluginId)?.push(instanceAddress);
}
return Array.from(resolutions.entries()).map(([pluginId, targets]) => ({
pluginId,
hosts: Array.from(targets).filter(
(target): target is { external: string; internal: string } =>
Object.keys(target).length > 0,
),
}));
}
public async getHosts(): Promise<
ReadonlyArray<string | { external: string; internal: string }>
> {
const resolutions = await this.#hostDiscovery.listResolutions();
const hosts = new Set<string | { external: string; internal: string }>();
for (const [_, targets] of resolutions.entries()) {
for (const target of targets) {
hosts.add(target as string | { external: string; internal: string });
}
}
return Array.from(hosts);
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2025 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 { LoggerService } from '@backstage/backend-plugin-api';
import type { RootSystemMetadataService } from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
export async function createSystemMetadataRouter(options: {
logger: LoggerService;
systemMetadata: RootSystemMetadataService;
}) {
const { systemMetadata } = options;
const router = Router();
router.get('/hosts', async (_, res) => {
const hosts = await systemMetadata.getHosts();
res.json({ items: hosts });
});
router.get('/plugins/installed', async (_, res) => {
res.json(await systemMetadata.getInstalledPlugins());
});
return router;
}
@@ -18,7 +18,7 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultSystemMetadataService } from './lib/DefaultSystemMetadataService';
import { DefaultRootSystemMetadataService } from './lib/DefaultRootSystemMetadataService';
import { createSystemMetadataRouter } from './lib/createSystemMetadataRouter';
/**
@@ -26,17 +26,19 @@ import { createSystemMetadataRouter } from './lib/createSystemMetadataRouter';
*
* @alpha
*/
export const systemMetadataServiceFactory = createServiceFactory({
service: coreServices.systemMetadata,
export const rootSystemMetadataServiceFactory = createServiceFactory({
service: coreServices.rootSystemMetadata,
deps: {
logger: coreServices.rootLogger,
config: coreServices.rootConfig,
httpRouter: coreServices.rootHttpRouter,
instanceMetadata: coreServices.rootInstanceMetadata,
},
async factory({ logger, config, httpRouter }) {
const systemMetadata = DefaultSystemMetadataService.create({
async factory({ logger, config, httpRouter, instanceMetadata }) {
const systemMetadata = DefaultRootSystemMetadataService.create({
logger,
config,
instanceMetadata,
});
const router = await createSystemMetadataRouter({ systemMetadata, logger });
@@ -1,187 +0,0 @@
/*
* Copyright 2024 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 {
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import {
BackstageInstance,
SystemMetadataService,
} from '@backstage/backend-plugin-api';
import { Observable } from '@backstage/types';
import z from 'zod';
import ObservableImpl from 'zen-observable';
const targetObjectSchema = z.object({
internal: z.string(),
external: z.string(),
});
/**
* A basic implementation of ReactiveX behavior subjects.
*
* A subject is a convenient way to create an observable when you want
* to fan out a single value to all subscribers.
*
* The BehaviorSubject will emit the most recently emitted value or error
* whenever a new observer subscribes to the subject.
*
* See http://reactivex.io/documentation/subject.html
*
* FORKED FROM core-app-api - where should this live?
*/
export class BehaviorSubject<T>
implements Observable<T>, ZenObservable.SubscriptionObserver<T>
{
private isClosed: boolean;
private currentValue: T;
private terminatingError: Error | undefined;
private readonly observable: Observable<T>;
constructor(value: T) {
this.isClosed = false;
this.currentValue = value;
this.terminatingError = undefined;
this.observable = new ObservableImpl<T>(subscriber => {
if (this.isClosed) {
if (this.terminatingError) {
subscriber.error(this.terminatingError);
} else {
subscriber.complete();
}
return () => {};
}
subscriber.next(this.currentValue);
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
}
private readonly subscribers = new Set<
ZenObservable.SubscriptionObserver<T>
>();
[Symbol.observable]() {
return this;
}
get closed() {
return this.isClosed;
}
next(value: T) {
if (this.isClosed) {
throw new Error('BehaviorSubject is closed');
}
this.currentValue = value;
this.subscribers.forEach(subscriber => subscriber.next(value));
}
error(error: Error) {
if (this.isClosed) {
throw new Error('BehaviorSubject is closed');
}
this.isClosed = true;
this.terminatingError = error;
this.subscribers.forEach(subscriber => subscriber.error(error));
}
complete() {
if (this.isClosed) {
throw new Error('BehaviorSubject is closed');
}
this.isClosed = true;
this.subscribers.forEach(subscriber => subscriber.complete());
}
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription;
subscribe(
onNext: (value: T) => void,
onError?: (error: any) => void,
onComplete?: () => void,
): ZenObservable.Subscription;
subscribe(
onNext: ZenObservable.Observer<T> | ((value: T) => void),
onError?: (error: any) => void,
onComplete?: () => void,
): ZenObservable.Subscription {
const observer =
typeof onNext === 'function'
? {
next: onNext,
error: onError,
complete: onComplete,
}
: onNext;
return this.observable.subscribe(observer);
}
}
/**
* @alpha
*/
export class DefaultSystemMetadataService implements SystemMetadataService {
private instance$: BehaviorSubject<BackstageInstance[]>;
constructor(
private options: { logger: LoggerService; config: RootConfigService },
) {
const getInstances = () => {
const endpoints =
options.config.getOptionalConfigArray('discovery.instances') ?? [];
const instances: BackstageInstance[] = [];
for (const endpoint of endpoints) {
const baseUrl = endpoint.getOptional('baseUrl');
if (baseUrl) {
if (typeof baseUrl === 'string') {
instances.push({ internalUrl: baseUrl, externalUrl: baseUrl });
} else {
const parseAttempt = targetObjectSchema.safeParse(baseUrl);
if (parseAttempt.success) {
const { internal, external } = parseAttempt.data;
instances.push({
internalUrl: internal,
externalUrl: external,
});
}
}
}
}
return instances;
};
this.instance$ = new BehaviorSubject(getInstances());
this.options.config.subscribe?.(() => {
this.instance$.next(getInstances());
});
}
public static create(pluginEnv: {
logger: LoggerService;
config: RootConfigService;
}) {
return new DefaultSystemMetadataService(pluginEnv);
}
instances(): Observable<BackstageInstance[]> {
return this.instance$;
}
}
@@ -1,85 +0,0 @@
/*
* Copyright 2025 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 { LoggerService } from '@backstage/backend-plugin-api';
import { BackendFeatureMeta } from '@backstage/backend-plugin-api/alpha';
import type {
BackstageInstance,
SystemMetadataService,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
export async function createSystemMetadataRouter(options: {
logger: LoggerService;
systemMetadata: SystemMetadataService;
}) {
const { logger, systemMetadata } = options;
let instances: BackstageInstance[] = [];
systemMetadata.instances().subscribe({
next: value => {
instances = value;
},
});
logger.info(`Instances in this system: ${JSON.stringify(instances)}`);
const router = Router();
router.get('/instances', async (_, res) => {
res.json({ items: instances });
});
router.get('/features/installed', async (_, res) => {
const featurePromises = await Promise.allSettled(
instances.map(async instance => {
const response = await fetch(
`${instance.internalUrl}/.backstage/instanceMetadata/v1/features/installed`,
);
if (response.ok) {
return { instance, response: await response.json() };
}
throw new Error(
`Failed to fetch installed features from ${instance.internalUrl}`,
);
}),
);
const pluginByInstance: Record<
string,
{ internalUrl: string; externalUrl: string }[]
> = {};
for (const result of featurePromises) {
if (result.status !== 'fulfilled') {
logger.error(`Failed to fetch installed features: ${result.reason}`);
continue;
}
const instance = result.value.instance;
const installedFeatures = result.value.response
.items as BackendFeatureMeta[];
for (const feature of installedFeatures) {
if (feature.type === 'plugin') {
if (!pluginByInstance[feature.pluginId]) {
pluginByInstance[feature.pluginId] = [];
}
pluginByInstance[feature.pluginId].push(instance);
}
}
}
res.json(pluginByInstance);
});
return router;
}
@@ -14,19 +14,19 @@
* limitations under the License.
*/
import { Observable } from '@backstage/types';
/**
* @public
*/
export interface BackstageInstance {
internalUrl: string;
externalUrl: string;
/** @public */
export interface RootSystemMetadataServicePluginInfo {
readonly pluginId: string;
readonly hosts: (string | { external: string; internal: string })[];
}
/**
* @public
*/
export interface SystemMetadataService {
instances(): Observable<BackstageInstance[]>;
/** @public */
export interface RootSystemMetadataService {
getInstalledPlugins: () => Promise<
ReadonlyArray<RootSystemMetadataServicePluginInfo>
>;
getHosts: () => Promise<
ReadonlyArray<string | { external: string; internal: string }>
>;
}
@@ -289,15 +289,15 @@ export namespace coreServices {
id: 'core.rootInstanceMetadata',
scope: 'root',
});
/**
* Read information about your current Backstage deployment.
* @public
*/
export const systemMetadata = createServiceRef<
import('./SystemMetadataService').SystemMetadataService
export const rootSystemMetadata = createServiceRef<
import('./RootSystemMetadataService').RootSystemMetadataService
>({
id: 'core.systemMetadata',
id: 'core.rootSystemMetadata',
scope: 'root',
});
}
@@ -73,9 +73,9 @@ export type {
SchedulerServiceTaskScheduleDefinitionConfig,
} from './SchedulerService';
export type {
BackstageInstance,
SystemMetadataService,
} from './SystemMetadataService';
RootSystemMetadataServicePluginInfo,
RootSystemMetadataService,
} from './RootSystemMetadataService';
export type {
UrlReaderService,
UrlReaderServiceReadTreeOptions,
@@ -1,34 +0,0 @@
/*
* Copyright 2025 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 { BackstageInstance } from '@backstage/backend-plugin-api';
import { MockSystemMetadataService } from './MockSystemMetadataService';
describe('MockSystemMetadataService', () => {
it('should return the passed in instances', () => {
expect.assertions(1);
const instances: BackstageInstance[] = [
{ internalUrl: 'localhost:7007', externalUrl: 'external.url' },
{ internalUrl: 'localhost:7008', externalUrl: 'other.external.url' },
];
const service = MockSystemMetadataService.create({ instances });
service.instances().subscribe({
next: value => {
expect(value).toEqual(instances);
},
});
});
});
@@ -1,44 +0,0 @@
/*
* Copyright 2025 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 {
BackstageInstance,
SystemMetadataService,
} from '@backstage/backend-plugin-api';
import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
/**
* @public
*/
export class MockSystemMetadataService implements SystemMetadataService {
#instances: BackstageInstance[];
constructor(instances: BackstageInstance[]) {
this.#instances = instances;
}
public static create(options: { instances: BackstageInstance[] }) {
return new MockSystemMetadataService(options.instances);
}
instances(): Observable<BackstageInstance[]> {
return new ObservableImpl<BackstageInstance[]>(subscriber => {
subscriber.next(this.#instances);
subscriber.complete();
});
}
}
@@ -30,7 +30,6 @@ import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
import {
AuthService,
BackstageCredentials,
BackstageInstance,
BackstageUserInfo,
DatabaseService,
DiscoveryService,
@@ -41,7 +40,6 @@ import {
SchedulerService,
ServiceFactory,
ServiceRef,
SystemMetadataService,
UserInfoService,
coreServices,
createServiceFactory,
@@ -63,8 +61,6 @@ import { simpleMock } from './simpleMock';
import { MockSchedulerService } from './MockSchedulerService';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ObservableConfigProxy } from '../../../config-loader/src/sources/ObservableConfigProxy';
import { MockSystemMetadataService } from './MockSystemMetadataService';
import { createMockObservable } from './MockObservable';
/** @internal */
function createLoggerMock() {
@@ -577,33 +573,23 @@ export namespace mockServices {
rootInstanceMetadata,
);
}
/**
* Creates a functional mock implementation for the
* {@link @backstage/backend-plugin-api#coreServices.systemMetadata}.
*/
export function systemMetadata(options: {
instances: BackstageInstance[];
}): SystemMetadataService {
return MockSystemMetadataService.create(options);
}
export namespace systemMetadata {
export namespace rootSystemMetadata {
/**
* Creates a functional mock factory for the
* {@link @backstage/backend-plugin-api#coreServices.systemMetadata}.
*/
export const factory = simpleFactoryWithOptions(
coreServices.systemMetadata,
systemMetadata,
coreServices.rootSystemMetadata,
rootSystemMetadata,
);
/**
* Creates a mock of the
* {@link @backstage/backend-events-node#systemMetadata}, optionally
* with some given method implementations.
*/
export const mock = simpleMock(coreServices.systemMetadata, () => ({
instances: jest
.fn()
.mockReturnValue(createMockObservable<BackstageInstance[]>([])),
export const mock = simpleMock(coreServices.rootSystemMetadata, () => ({
getInstalledPlugins: jest.fn(),
getHosts: jest.fn(),
}));
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ import {
coreServices,
createBackendFeatureLoader,
} from '@backstage/backend-plugin-api';
import { systemMetadataServiceFactory } from '@backstage/backend-defaults/alpha';
import { rootSystemMetadataServiceFactory } from '@backstage/backend-defaults/rootSystemMetadata';
const backend = createBackend();
@@ -70,7 +70,7 @@ backend.add(searchLoader);
backend.add(import('@backstage/plugin-techdocs-backend'));
backend.add(import('@backstage/plugin-signals-backend'));
backend.add(import('@backstage/plugin-notifications-backend'));
backend.add(systemMetadataServiceFactory);
backend.add(rootSystemMetadataServiceFactory);
backend.add(import('@backstage/plugin-events-backend-module-google-pubsub'));
backend.add(import('@backstage/plugin-mcp-actions-backend'));