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
-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;
}