diff --git a/.changeset/itchy-files-deny.md b/.changeset/itchy-files-deny.md new file mode 100644 index 0000000000..9a3d4918bf --- /dev/null +++ b/.changeset/itchy-files-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Implemented SRV lookup support in the default `HostDiscovery`. You can now specify internal URLs on the form `http+srv://some-srv-name/api/{{pluginId}}` and they will be resolved in real time. diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index efd14afc2e..a0c8eaaa13 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -730,18 +730,62 @@ export interface Config { */ discovery?: { /** - * A list of target baseUrls and the associated plugins. + * A list of target base URLs and their associated plugins. + * + * @example + * + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permission] + * - target: + * internal: http+srv://backstage-plugin-{{pluginId}}.http.${SERVICE_DOMAIN}/search + * external: https://example.com/search + * plugins: [search] + * ``` */ endpoints: Array<{ /** - * The target base URL to use for the plugin. + * The target base URL to use for the given set of plugins. Note that this + * needs to be a full URL including the protocol and path parts that fully + * address the root of a plugin's API endpoints. * - * Can be either a string or an object with internal and external keys. - * Targets with `{{pluginId}}` or `{{ pluginId }} in the URL will be replaced with the plugin ID. + * @remarks + * + * Can be either a single URL or an object where you can explicitly give a + * dedicated URL for internal (as seen from the backend) and/or external (as + * seen from the frontend) lookups. + * + * The default behavior is to use the backend base URL for external lookups, + * and a URL formed from the `.listen` and `.https` configs for internal + * lookups. Adding discovery endpoints as described here overrides one or both + * of those behaviors for a given set of plugins. + * + * URLs can be in the form of a regular HTTP or HTTPS URL if you are using + * A/AAAA/CNAME records or IP addresses. Specifically for internal URLs, if + * you add `+grpc` to the protocol part then the hostname is treated as an SRV + * record name and resolved. For example, if you pass in + * `http+srv:///path` then the record part is resolved into an + * actual host and port (with random weighted choice as usual when there is + * more than one match). + * + * Any strings with `{{pluginId}}` or `{{ pluginId }}` placeholders in them + * will have them replaced with the plugin ID. + * + * Example URLs: + * + * - `https://internal.example.com/secure/api/{{pluginId}}` + * - `http+srv://backstage-plugin-{{pluginId}}.http.${SERVICE_DOMAIN}/api/{{pluginId}}` + * (can only be used in the `internal` key) */ target: string | { internal?: string; external?: string }; /** - * Array of plugins which use the target base URL. + * Array of plugins which use that target base URL. + * + * The special value `*` can be used to match all plugins. */ plugins: string[]; }>; diff --git a/packages/backend-defaults/report-discovery.api.md b/packages/backend-defaults/report-discovery.api.md index d168d33a71..ddbbd9d025 100644 --- a/packages/backend-defaults/report-discovery.api.md +++ b/packages/backend-defaults/report-discovery.api.md @@ -4,6 +4,7 @@ ```ts import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { RootConfigService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -16,12 +17,33 @@ export const discoveryServiceFactory: ServiceFactory< // @public export class HostDiscovery implements DiscoveryService { - static fromConfig(config: RootConfigService): HostDiscovery; + // (undocumented) + static fromConfig( + config: RootConfigService, + options?: HostDiscoveryOptions, + ): HostDiscovery; // (undocumented) getBaseUrl(pluginId: string): Promise; // (undocumented) getExternalBaseUrl(pluginId: string): Promise; } +// @public +export interface HostDiscoveryEndpoint { + plugins: string[]; + target: + | string + | { + internal?: string; + external?: string; + }; +} + +// @public +export interface HostDiscoveryOptions { + defaultEndpoints?: HostDiscoveryEndpoint[]; + logger: LoggerService; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts index 60ea9d7c3b..1eb2d7d42b 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.test.ts @@ -15,6 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; import { HostDiscovery } from './HostDiscovery'; describe('HostDiscovery', () => { @@ -271,4 +272,283 @@ describe('HostDiscovery', () => { 'http://localhost:40/api/plugin%2Falpha', ); }); + + it('accepts default endpoints with lower prio than config', async () => { + const discovery = HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://config.com/1', + plugins: ['a'], + }, + { + target: { internal: 'http://config.com/1' }, + plugins: ['b'], + }, + { + target: { external: 'http://config.com/1' }, + plugins: ['c'], + }, + { + target: 'http://config.com/2', + plugins: ['d'], + }, + { + target: { internal: 'http://config.com/2' }, + plugins: ['e'], + }, + { + target: { external: 'http://config.com/2' }, + plugins: ['f'], + }, + { + target: 'http://config.com/3', + plugins: ['g'], + }, + { + target: { internal: 'http://config.com/3' }, + plugins: ['h'], + }, + { + target: { external: 'http://config.com/3' }, + plugins: ['i'], + }, + ], + }, + }, + }), + { + logger: mockServices.logger.mock(), + defaultEndpoints: [ + { + target: 'http://default.com/1', + plugins: ['a', 'b', 'c'], + }, + { + target: { internal: 'http://default.com/2' }, + plugins: ['d', 'e', 'f'], + }, + { + target: { external: 'http://default.com/3' }, + plugins: ['g', 'h', 'i'], + }, + ], + }, + ); + + await expect(discovery.getBaseUrl('unknown')).resolves.toBe( + 'http://localhost:80/api/unknown', + ); + await expect(discovery.getExternalBaseUrl('unknown')).resolves.toBe( + 'http://localhost:40/api/unknown', + ); + + await expect(discovery.getBaseUrl('a')).resolves.toBe( + 'http://config.com/1', + ); + await expect(discovery.getExternalBaseUrl('a')).resolves.toBe( + 'http://config.com/1', + ); + + await expect(discovery.getBaseUrl('b')).resolves.toBe( + 'http://config.com/1', + ); + await expect(discovery.getExternalBaseUrl('b')).resolves.toBe( + 'http://default.com/1', + ); + + await expect(discovery.getBaseUrl('c')).resolves.toBe( + 'http://default.com/1', + ); + await expect(discovery.getExternalBaseUrl('c')).resolves.toBe( + 'http://config.com/1', + ); + + await expect(discovery.getBaseUrl('d')).resolves.toBe( + 'http://config.com/2', + ); + await expect(discovery.getExternalBaseUrl('d')).resolves.toBe( + 'http://config.com/2', + ); + + await expect(discovery.getBaseUrl('e')).resolves.toBe( + 'http://config.com/2', + ); + await expect(discovery.getExternalBaseUrl('e')).resolves.toBe( + 'http://localhost:40/api/e', + ); + + await expect(discovery.getBaseUrl('f')).resolves.toBe( + 'http://default.com/2', + ); + await expect(discovery.getExternalBaseUrl('f')).resolves.toBe( + 'http://config.com/2', + ); + + await expect(discovery.getBaseUrl('g')).resolves.toBe( + 'http://config.com/3', + ); + await expect(discovery.getExternalBaseUrl('g')).resolves.toBe( + 'http://config.com/3', + ); + + await expect(discovery.getBaseUrl('h')).resolves.toBe( + 'http://config.com/3', + ); + await expect(discovery.getExternalBaseUrl('h')).resolves.toBe( + 'http://default.com/3', + ); + + await expect(discovery.getBaseUrl('i')).resolves.toBe( + 'http://localhost:80/api/i', + ); + await expect(discovery.getExternalBaseUrl('i')).resolves.toBe( + 'http://config.com/3', + ); + }); + + it('only accepts SRV URLs in the internal target', async () => { + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }, + }), + { + logger: mockServices.logger.mock(), + defaultEndpoints: [ + { + target: { internal: 'http+srv://default.com/1' }, + plugins: ['a'], + }, + ], + }, + ), + ).not.toThrow(); + + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: { internal: 'http+srv://default.com/1' }, + plugins: ['a'], + }, + ], + }, + }, + }), + ), + ).not.toThrow(); + + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }, + }), + { + logger: mockServices.logger.mock(), + defaultEndpoints: [ + { + target: 'http+srv://default.com/1', + plugins: ['a'], + }, + ], + }, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot be used in the target for external endpoints"`, + ); + + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }, + }), + { + logger: mockServices.logger.mock(), + defaultEndpoints: [ + { + target: { external: 'http+srv://default.com/1' }, + plugins: ['a'], + }, + ], + }, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot be used in the target for external endpoints"`, + ); + + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http+srv://default.com/1', + plugins: ['a'], + }, + ], + }, + }, + }), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot be used in the target for external endpoints"`, + ); + + expect(() => + HostDiscovery.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: { external: 'http+srv://default.com/1' }, + plugins: ['a'], + }, + ], + }, + }, + }), + ), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot be used in the target for external endpoints"`, + ); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts index 214a347040..6d5af450b0 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/HostDiscovery.ts @@ -17,53 +17,189 @@ import { Config } from '@backstage/config'; import { DiscoveryService, + LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; import { readHttpServerOptions } from '../rootHttpRouter/http/config'; +import { SrvResolvers } from './SrvResolvers'; +import { trimEnd } from 'lodash'; -type Target = string | { internal: string; external: string }; +type Resolver = (pluginId: string) => Promise; /** - * HostDiscovery is a basic DiscoveryService implementation - * that can handle plugins that are hosted in a single or multiple deployments. - * - * The deployment may be scaled horizontally, as long as the external URL - * is the same for all instances. However, internal URLs will always be - * resolved to the same host, so there won't be any balancing of internal traffic. + * A list of target base URLs and their associated plugins. * * @public */ -export class HostDiscovery implements DiscoveryService { +export interface HostDiscoveryEndpoint { /** - * Creates a new HostDiscovery discovery instance by reading - * from the `backend` config section, specifically the `.baseUrl` for - * discovering the external URL, and the `.listen` and `.https` config - * for the internal one. + * The target base URL to use for the given set of plugins. Note that this + * needs to be a full URL _including_ the protocol and path parts that fully + * address the root of a plugin's API endpoints. * - * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`. - * eg. + * @remarks * - * ```yaml - * discovery: - * endpoints: - * - target: https://internal.example.com/internal-catalog - * plugins: [catalog] - * - target: https://internal.example.com/secure/api/{{pluginId}} - * plugins: [auth, permission] - * - target: - * internal: https://internal.example.com/search - * external: https://example.com/search - * plugins: [search] - * ``` + * Can be either a single URL or an object where you can explicitly give a + * dedicated URL for internal (as seen from the backend) and/or external (as + * seen from the frontend) lookups. * - * The fixed base path is `/api`, meaning the default full internal - * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. + * The default behavior is to use the backend base URL for external lookups, + * and a URL formed from the `.listen` and `.https` configs for internal + * lookups. Adding discovery endpoints as described here overrides one or both + * of those behaviors for a given set of plugins. + * + * URLs can be in the form of a regular HTTP or HTTPS URL if you are using + * A/AAAA/CNAME records or IP addresses. Specifically for internal URLs, if + * you add `+grpc` to the protocol part then the hostname is treated as an SRV + * record name and resolved. For example, if you pass in + * `http+srv:///path` then the record part is resolved into an + * actual host and port (with random weighted choice as usual when there is + * more than one match). + * + * Any strings with `{{pluginId}}` or `{{ pluginId }}` placeholders in them + * will have them replaced with the plugin ID. + * + * Example URLs: + * + * - `https://internal.example.com/secure/api/{{ pluginId }}` + * - `http+srv://backstage-plugin-{{pluginId}}.http.services.company.net/api/{{pluginId}}` + * (can only be used in the `internal` key) */ - static fromConfig(config: RootConfigService) { - const basePath = '/api'; - const externalBaseUrl = config - .getString('backend.baseUrl') - .replace(/\/+$/, ''); + target: + | string + | { + internal?: string; + external?: string; + }; + + /** + * Array of plugins which use that target base URL. + * + * The special value `*` can be used to match all plugins. + */ + plugins: string[]; +} + +/** + * Options for the {@link HostDiscovery} class. + * + * @public + */ +export interface HostDiscoveryOptions { + /** + * The logger to use. + */ + logger: LoggerService; + + /** + * A default set of endpoints to use. + * + * @remarks + * + * These endpoints have lower priority than any that are defined in + * app-config, but higher priority than the fallback ones. + * + * This parameter is usedful for example if you want to provide a shared + * library of core services to your plugin developers, which is set up for the + * default behaviors in your org. This alleviates the need fo replicating any + * given set of endpoint config in every backend that you deploy. + */ + defaultEndpoints?: HostDiscoveryEndpoint[]; +} + +/** + * A basic {@link @backstage/backend-plugin-api#DiscoveryService} implementation + * that can handle plugins that are hosted in a single or multiple deployments. + * + * @public + * @remarks + * + * Configuration is read from the `backend` config section, specifically the + * `.baseUrl` for discovering the external URL, and the `.listen` and `.https` + * config for the internal one. The fixed base path for these is `/api`, meaning + * for example the default full internal path for the `catalog` plugin typically + * will be `http://localhost:7007/api/catalog`. + * + * Those defaults can be overridden by providing a target and corresponding + * plugins in `discovery.endpoints`, e.g.: + * + * ```yaml + * discovery: + * endpoints: + * # Set a static internal and external base URL for a plugin + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * # Sets a dynamic internal and external base URL pattern for two plugins + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permission] + * # Sets a dynamic base URL pattern for only the internal resolution for all + * # other plugins, while leaving the external resolution unaffected + * - target: + * internal: http+srv://backstage-plugin-{{pluginId}}.http.${SERVICE_DOMAIN}/api/{{pluginId}} + * plugins: [*] + * ``` + */ +export class HostDiscovery implements DiscoveryService { + #srvResolver: SrvResolvers; + #internalResolvers: Map = new Map(); + #externalResolvers: Map = new Map(); + #internalFallbackResolver: Resolver = async () => { + throw new Error('Not initialized'); + }; + #externalFallbackResolver: Resolver = async () => { + throw new Error('Not initialized'); + }; + + static fromConfig(config: RootConfigService, options?: HostDiscoveryOptions) { + const discovery = new HostDiscovery(new SrvResolvers()); + + discovery.#updateResolvers(config, options?.defaultEndpoints); + config.subscribe?.(() => { + try { + discovery.#updateResolvers(config, options?.defaultEndpoints); + } catch (e) { + options?.logger.error(`Failed to update discovery service: ${e}`); + } + }); + + return discovery; + } + + private constructor(srvResolver: SrvResolvers) { + this.#srvResolver = srvResolver; + this.#internalResolvers = new Map(); + this.#externalResolvers = new Map(); + this.#internalFallbackResolver = () => { + throw new Error('Not initialized'); + }; + this.#externalFallbackResolver = () => { + throw new Error('Not initialized'); + }; + } + + async getBaseUrl(pluginId: string): Promise { + const resolver = + this.#internalResolvers.get(pluginId) ?? + this.#internalResolvers.get('*') ?? + this.#internalFallbackResolver; + return await resolver(pluginId); + } + + async getExternalBaseUrl(pluginId: string): Promise { + const resolver = + this.#externalResolvers.get(pluginId) ?? + this.#externalResolvers.get('*') ?? + this.#externalFallbackResolver; + return await resolver(pluginId); + } + + #updateResolvers(config: Config, defaultEndpoints?: HostDiscoveryEndpoint[]) { + this.#updateFallbackResolvers(config); + this.#updatePluginResolvers(config, defaultEndpoints); + } + + #updateFallbackResolvers(config: Config) { + const backendBaseUrl = trimEnd(config.getString('backend.baseUrl'), '/'); const { listen: { host: listenHost = '::', port: listenPort }, @@ -84,49 +220,107 @@ export class HostDiscovery implements DiscoveryService { host = `[${host}]`; } - const internalBaseUrl = `${protocol}://${host}:${listenPort}`; - - return new HostDiscovery( - internalBaseUrl + basePath, - externalBaseUrl + basePath, - config.getOptionalConfig('discovery'), + this.#internalFallbackResolver = this.#makeResolver( + `${protocol}://${host}:${listenPort}/api/{{pluginId}}`, + false, + ); + this.#externalFallbackResolver = this.#makeResolver( + `${backendBaseUrl}/api/{{pluginId}}`, + false, ); } - private constructor( - private readonly internalBaseUrl: string, - private readonly externalBaseUrl: string, - private readonly discoveryConfig: Config | undefined, - ) {} + #updatePluginResolvers( + config: Config, + defaultEndpoints?: HostDiscoveryEndpoint[], + ) { + // Start out with the default endpoints, if any + const endpoints = defaultEndpoints?.slice() ?? []; - private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') { - const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); - - const targetOrObj = endpoints - ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) - ?.get('target'); - - const target = - typeof targetOrObj === 'string' ? targetOrObj : targetOrObj?.[type]; - - if (!target) { - const baseUrl = - type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; - - return `${baseUrl}/${encodeURIComponent(pluginId)}`; + // Allow config to override the default endpoints + const endpointConfigs = config.getOptionalConfigArray( + 'discovery.endpoints', + ); + for (const endpointConfig of endpointConfigs ?? []) { + if (typeof endpointConfig.get('target') === 'string') { + endpoints.push({ + target: endpointConfig.getString('target'), + plugins: endpointConfig.getStringArray('plugins'), + }); + } else { + endpoints.push({ + target: { + internal: endpointConfig.getOptionalString('target.internal'), + external: endpointConfig.getOptionalString('target.external'), + }, + plugins: endpointConfig.getStringArray('plugins'), + }); + } } - return target.replace( - /\{\{\s*pluginId\s*\}\}/g, - encodeURIComponent(pluginId), - ); + // Build up a new set of resolvers + const internalResolvers: Map = new Map(); + const externalResolvers: Map = new Map(); + for (const { target, plugins } of endpoints) { + let internalResolver: Resolver | undefined; + let externalResolver: Resolver | undefined; + + if (typeof target === 'string') { + internalResolver = externalResolver = this.#makeResolver(target, false); + } else { + if (target.internal) { + internalResolver = this.#makeResolver(target.internal, true); + } + if (target.external) { + externalResolver = this.#makeResolver(target.external, false); + } + } + + if (internalResolver) { + for (const pluginId of plugins) { + internalResolvers.set(pluginId, internalResolver); + } + } + if (externalResolver) { + for (const pluginId of plugins) { + externalResolvers.set(pluginId, externalResolver); + } + } + } + + // Only persist if no errors were thrown above + this.#internalResolvers = internalResolvers; + this.#externalResolvers = externalResolvers; } - async getBaseUrl(pluginId: string): Promise { - return this.getTargetFromConfig(pluginId, 'internal'); - } + #makeResolver(urlPattern: string, allowSrv: boolean): Resolver { + const withPluginId = (pluginId: string, url: string) => { + return url.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + }; - async getExternalBaseUrl(pluginId: string): Promise { - return this.getTargetFromConfig(pluginId, 'external'); + if (!this.#srvResolver.isSrvUrl(urlPattern)) { + return async pluginId => withPluginId(pluginId, urlPattern); + } + + if (!allowSrv) { + throw new Error( + `SRV resolver URLs cannot be used in the target for external endpoints`, + ); + } + + const lazyResolvers = new Map Promise>(); + return async pluginId => { + let lazyResolver = lazyResolvers.get(pluginId); + if (!lazyResolver) { + lazyResolver = this.#srvResolver.getResolver( + withPluginId(pluginId, urlPattern), + ); + lazyResolvers.set(pluginId, lazyResolver); + } + return await lazyResolver(); + }; } } diff --git a/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.test.ts b/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.test.ts new file mode 100644 index 0000000000..7b7109bd22 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.test.ts @@ -0,0 +1,213 @@ +/* + * 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 { SrvResolvers } from './SrvResolvers'; + +describe('SrvResolvers', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + describe('isSrvUrl', () => { + it('distinguishes SRV URLs', () => { + const resolvers = new SrvResolvers({ + resolveSrv: () => Promise.resolve([]), + }); + + expect(resolvers.isSrvUrl('http+srv://example.com')).toBe(true); + expect(resolvers.isSrvUrl('http+srv://example.com/')).toBe(true); + expect(resolvers.isSrvUrl('http+srv://example.com/a/{{pluginId}}')).toBe( + true, + ); + expect(resolvers.isSrvUrl('https+srv://example.com')).toBe(true); + expect(resolvers.isSrvUrl('https+srv://example.com/')).toBe(true); + expect(resolvers.isSrvUrl('https+srv://example.com/a/{{pluginId}}')).toBe( + true, + ); + + expect(resolvers.isSrvUrl('ftp+srv://example.com/a/{{pluginId}}')).toBe( + false, + ); + expect(resolvers.isSrvUrl('https://example.com/a/{{pluginId}}')).toBe( + false, + ); + expect(resolvers.isSrvUrl('://')).toBe(false); + expect(resolvers.isSrvUrl('broken')).toBe(false); + }); + }); + + describe('getResolver', () => { + it('throws for invalid URLs', () => { + const resolvers = new SrvResolvers({ + resolveSrv: () => Promise.resolve([]), + }); + expect(() => + resolvers.getResolver('://'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver expected a valid URL starting with http(s)+srv:// but got '://'"`, + ); + expect(() => + resolvers.getResolver('https://example.com/a/{{pluginId}}'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver expected a URL with protocol http(s)+srv:// but got 'https://example.com/a/{{pluginId}}'"`, + ); + expect(() => + resolvers.getResolver('http+srv://example.com:8080'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot contain a port but got 'http+srv://example.com:8080'"`, + ); + expect(() => + resolvers.getResolver('http+srv://a:b@example.com'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot contain username or password but got 'http+srv://a:b@example.com'"`, + ); + expect(() => + resolvers.getResolver('http+srv://example.com?a=1'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot contain search params or a hash but got 'http+srv://example.com?a=1'"`, + ); + expect(() => + resolvers.getResolver('http+srv://example.com#a'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV resolver URLs cannot contain search params or a hash but got 'http+srv://example.com#a'"`, + ); + expect(() => + resolvers.getResolver('ftp+srv://example.com/a/{{pluginId}}'), + ).toThrowErrorMatchingInlineSnapshot( + `"SRV URLs must be based on http or https but got 'ftp+srv://example.com/a/{{pluginId}}'"`, + ); + }); + + it('works for simple cases', async () => { + const resolveSrv = jest.fn(async (host: string) => { + expect(host).toBe('input.example.com'); + return [ + { + name: 'output.example.com', + port: 8080, + priority: 10, + weight: 10, + }, + ]; + }); + + const resolvers = new SrvResolvers({ resolveSrv, cacheTtlMillis: 100 }); + + await expect( + resolvers.getResolver('http+srv://input.example.com')(), + ).resolves.toEqual('http://output.example.com:8080'); + await expect( + resolvers.getResolver('https+srv://input.example.com')(), + ).resolves.toEqual('https://output.example.com:8080'); + await expect( + resolvers.getResolver('http+srv://input.example.com/some/path')(), + ).resolves.toEqual('http://output.example.com:8080/some/path'); + }); + + it('only picks among the highest priority records', async () => { + const resolveSrv = jest.fn(async (host: string) => { + expect(host).toBe('input.example.com'); + return [ + { + name: 'output1.example.com', + port: 8081, + priority: 10, + weight: 10, + }, + { + name: 'output1.example.com', + port: 8082, + priority: 10, + weight: 20, + }, + { + name: 'output2.example.com', + port: 8083, + priority: 20, + weight: 10, + }, + ]; + }); + + const resolvers = new SrvResolvers({ resolveSrv }); + + const resolver = resolvers.getResolver('http+srv://input.example.com'); + for (let i = 0; i < 1000; i++) { + expect([ + 'http://output1.example.com:8081', + 'http://output1.example.com:8082', + ]).toContain(await resolver()); + } + }); + + it('uses caching', async () => { + jest.useFakeTimers(); + + const resolveSrv = jest.fn(async (host: string) => { + expect(host).toBe('input.example.com'); + return [ + { + name: 'output.example.com', + port: 8080, + priority: 10, + weight: 10, + }, + ]; + }); + + const resolvers = new SrvResolvers({ resolveSrv, cacheTtlMillis: 100 }); + const resolver1 = resolvers.getResolver('http+srv://input.example.com/a'); + const resolver2 = resolvers.getResolver('http+srv://input.example.com/b'); + + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver2()).resolves.toEqual( + 'http://output.example.com:8080/b', + ); + expect(resolveSrv).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(99); + + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver2()).resolves.toEqual( + 'http://output.example.com:8080/b', + ); + expect(resolveSrv).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1); + + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver1()).resolves.toEqual( + 'http://output.example.com:8080/a', + ); + await expect(resolver2()).resolves.toEqual( + 'http://output.example.com:8080/b', + ); + expect(resolveSrv).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.ts b/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.ts new file mode 100644 index 0000000000..149fb92ef7 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/discovery/SrvResolvers.ts @@ -0,0 +1,188 @@ +/* + * 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 { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; +import { resolveSrv, SrvRecord } from 'dns'; + +const PROTOCOL_SUFFIX = '+srv:'; + +/** + * Helps with resolution and caching of SRV lookups. + * + * Supports URLs on the form `http+srv://myplugin.services.region.example.net/api/myplugin` + */ +export class SrvResolvers { + readonly #cache: Map>; + readonly #cacheTtlMillis: number; + readonly #resolveSrv: (host: string) => Promise; + + constructor(options?: { + resolveSrv?: (host: string) => Promise; + cacheTtlMillis?: number; + }) { + this.#cache = new Map(); + this.#cacheTtlMillis = options?.cacheTtlMillis ?? 1000; + this.#resolveSrv = + options?.resolveSrv ?? + (host => + new Promise((resolve, reject) => { + resolveSrv(host, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + })); + } + + isSrvUrl(url: string): boolean { + try { + this.#parseSrvUrl(url); + return true; + } catch { + return false; + } + } + + /** + * Get a resolver function for a given SRV form URL. + * + * @param url An SRV form URL, e.g. `http+srv://myplugin.services.region.example.net/api/myplugin` + * @returns A function that returns resolved URLs, e.g. `http://1234abcd.region.example.net:8080/api/myplugin` + */ + getResolver(url: string): () => Promise { + const { protocol, host, path } = this.#parseSrvUrl(url); + return () => + this.#resolveHost(host).then( + resolved => `${protocol}://${resolved}${path}`, + ); + } + + /** + * Attempts to parse out the relevant parts of an SRV URL. + */ + #parseSrvUrl(url: string): { protocol: string; host: string; path: string } { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new InputError( + `SRV resolver expected a valid URL starting with http(s)+srv:// but got '${url}'`, + ); + } + if (!parsedUrl.protocol?.endsWith(PROTOCOL_SUFFIX) || !parsedUrl.hostname) { + throw new InputError( + `SRV resolver expected a URL with protocol http(s)+srv:// but got '${url}'`, + ); + } + if (parsedUrl.port) { + throw new InputError( + `SRV resolver URLs cannot contain a port but got '${url}'`, + ); + } + if (parsedUrl.username || parsedUrl.password) { + throw new InputError( + `SRV resolver URLs cannot contain username or password but got '${url}'`, + ); + } + if (parsedUrl.search || parsedUrl.hash) { + throw new InputError( + `SRV resolver URLs cannot contain search params or a hash but got '${url}'`, + ); + } + + const protocol = parsedUrl.protocol.substring( + 0, + parsedUrl.protocol.length - PROTOCOL_SUFFIX.length, + ); + const host = parsedUrl.hostname; + const path = parsedUrl.pathname.replace(/\/+$/, ''); + + if (!['http', 'https'].includes(protocol)) { + throw new InputError( + `SRV URLs must be based on http or https but got '${url}'`, + ); + } + + return { protocol, host, path }; + } + + /** + * Resolves a single SRV record name to a host:port string. + */ + #resolveHost(host: string): Promise { + let records = this.#cache.get(host); + if (!records) { + records = this.#resolveSrv(host).then( + result => { + if (!result.length) { + throw new NotFoundError(`No SRV records found for ${host}`); + } + return result; + }, + err => { + throw new ForwardedError(`Failed SRV resolution for ${host}`, err); + }, + ); + this.#cache.set(host, records); + setTimeout(() => { + this.#cache.delete(host); + }, this.#cacheTtlMillis); + } + + return records.then(rs => { + const r = this.#pickRandomRecord(rs); + return `${r.name}:${r.port}`; + }); + } + + /** + * Among a set of records, pick one at random. + * + * This assumes that the set is not empty. + * + * Since this contract only ever returns a single record, the best it can do + * is to pick weighted-randomly among the highest-priority records. In order + * to be smarter than that, the caller would have to be able to make decisions + * on the whole set of records. + */ + #pickRandomRecord(allRecords: SrvRecord[]): SrvRecord { + // Lowest priority number means highest priority + const lowestPriority = allRecords.reduce( + (acc, r) => Math.min(acc, r.priority), + Number.MAX_SAFE_INTEGER, + ); + const records = allRecords.filter(r => r.priority === lowestPriority); + + const totalWeight = records.reduce((acc, r) => acc + r.weight, 0); + const targetWeight = Math.random() * totalWeight; + + // Just as a fallback, we expect the loop below to always find a result + let result = records[0]; + let currentWeight = 0; + + for (const record of records) { + currentWeight += record.weight; + if (targetWeight <= currentWeight) { + result = record; + break; + } + } + + return result; + } +} diff --git a/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts index 18ceac4a91..9555158ed5 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/discoveryServiceFactory.ts @@ -33,8 +33,12 @@ export const discoveryServiceFactory = createServiceFactory({ service: coreServices.discovery, deps: { config: coreServices.rootConfig, + logger: coreServices.logger, }, - async factory({ config }) { - return HostDiscovery.fromConfig(config); + async factory({ config, logger }) { + return HostDiscovery.fromConfig(config, { + logger, + defaultEndpoints: [], + }); }, }); diff --git a/packages/backend-defaults/src/entrypoints/discovery/index.ts b/packages/backend-defaults/src/entrypoints/discovery/index.ts index ee4851271a..9d49a463d3 100644 --- a/packages/backend-defaults/src/entrypoints/discovery/index.ts +++ b/packages/backend-defaults/src/entrypoints/discovery/index.ts @@ -15,4 +15,8 @@ */ export { discoveryServiceFactory } from './discoveryServiceFactory'; -export { HostDiscovery } from './HostDiscovery'; +export { + HostDiscovery, + type HostDiscoveryEndpoint, + type HostDiscoveryOptions, +} from './HostDiscovery';