implement SRV support in HostDiscovery
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user