implement SRV support in HostDiscovery

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2025-04-12 23:46:08 +02:00
parent 208d46983d
commit 7c6740eb1c
9 changed files with 1031 additions and 77 deletions
+5
View File
@@ -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.
+49 -5
View File
@@ -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://<srv-record>/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[];
}>;
@@ -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<string>;
// (undocumented)
getExternalBaseUrl(pluginId: string): Promise<string>;
}
// @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)
```
@@ -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"`,
);
});
});
@@ -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<string>;
/**
* 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://<record>/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<string, Resolver> = new Map();
#externalResolvers: Map<string, Resolver> = 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<string> {
const resolver =
this.#internalResolvers.get(pluginId) ??
this.#internalResolvers.get('*') ??
this.#internalFallbackResolver;
return await resolver(pluginId);
}
async getExternalBaseUrl(pluginId: string): Promise<string> {
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>('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<string, Resolver> = new Map();
const externalResolvers: Map<string, Resolver> = 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<string> {
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<string> {
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<string, () => Promise<string>>();
return async pluginId => {
let lazyResolver = lazyResolvers.get(pluginId);
if (!lazyResolver) {
lazyResolver = this.#srvResolver.getResolver(
withPluginId(pluginId, urlPattern),
);
lazyResolvers.set(pluginId, lazyResolver);
}
return await lazyResolver();
};
}
}
@@ -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);
});
});
});
@@ -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<string, Promise<SrvRecord[]>>;
readonly #cacheTtlMillis: number;
readonly #resolveSrv: (host: string) => Promise<SrvRecord[]>;
constructor(options?: {
resolveSrv?: (host: string) => Promise<SrvRecord[]>;
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<string> {
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<string> {
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;
}
}
@@ -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: [],
});
},
});
@@ -15,4 +15,8 @@
*/
export { discoveryServiceFactory } from './discoveryServiceFactory';
export { HostDiscovery } from './HostDiscovery';
export {
HostDiscovery,
type HostDiscoveryEndpoint,
type HostDiscoveryOptions,
} from './HostDiscovery';