From d0f0e28c5826b178c5d6aad6e5b44c2937bc7303 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 12:56:49 +0100 Subject: [PATCH] feat: Add HostDiscovery to backend-common Signed-off-by: Jack Palmer --- .../src/discovery/HostDiscovery.test.ts | 105 ++++++++++++++++++ .../src/discovery/HostDiscovery.ts | 97 ++++++++++++++++ .../src/discovery/SingleHostDiscovery.ts | 1 + .../backend-common/src/discovery/index.ts | 1 + 4 files changed, 204 insertions(+) create mode 100644 packages/backend-common/src/discovery/HostDiscovery.test.ts create mode 100644 packages/backend-common/src/discovery/HostDiscovery.ts diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts new file mode 100644 index 0000000000..c15ffd79e8 --- /dev/null +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2020 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 { ConfigReader } from '@backstage/config'; +import { HostDiscovery } from './HostDiscovery'; + +describe('HostDiscovery', () => { + it('is created from config', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/api/catalog', + ); + }); + + it('can configure the base path', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + { basePath: '/service' }, + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/service/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/service/catalog', + ); + }); + + it.each([ + [{ listen: ':80' }, 'http://localhost:80'], + [{ listen: ':40', https: true }, 'https://localhost:40'], + [{ listen: '127.0.0.1:80' }, 'http://127.0.0.1:80'], + [{ listen: '127.0.0.1:80', https: true }, 'https://127.0.0.1:80'], + [{ listen: '0.0.0.0:40' }, 'http://127.0.0.1:40'], + [{ listen: { port: 80 } }, 'http://localhost:80'], + [{ listen: { port: 8000 } }, 'http://localhost:8000'], + [{ listen: { port: 80, host: '0.0.0.0' } }, 'http://127.0.0.1:80'], + [{ listen: { port: 80, host: '::' } }, 'http://localhost:80'], + [{ listen: { port: 80, host: '::1' } }, 'http://[::1]:80'], + [{ listen: { port: 90, host: '::2' }, https: true }, 'https://[::2]:90'], + ])('resolves internal baseUrl for %j as %s', async (config, expected) => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + ...config, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + `${expected}/api/catalog`, + ); + }); + + it('uses plugin specific baseUrl from config if provided', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + discovery: { + catalog: 'http://catalog-backend:8080/api/catalog', + }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + }); +}); diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts new file mode 100644 index 0000000000..0fedf0643a --- /dev/null +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2023 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 { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from './types'; +import { readHttpServerOptions } from '@backstage/backend-app-api'; + +/** + * HostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * 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. + * + * @public + */ + +export class HostDiscovery implements PluginEndpointDiscovery { + /** + * 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. + * + * Can be overridden by in config by providing a `backend.discovery` section with a `` key. + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config.getString('backend.baseUrl'); + + const { + listen: { host: listenHost = '::', port: listenPort }, + } = readHttpServerOptions(config.getConfig('backend')); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::' || host === '') { + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new HostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + config.getOptionalConfig('backend.discovery'), + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + private readonly discoveryConfig: Config | undefined, + ) {} + + async getBaseUrl(pluginId: string): Promise { + if (this.discoveryConfig?.has(pluginId)) { + return this.discoveryConfig.getString(pluginId); + } + + return `${this.internalBaseUrl}/${pluginId}`; + } + + async getExternalBaseUrl(pluginId: string): Promise { + if (this.discoveryConfig?.has(pluginId)) { + return this.discoveryConfig.getString(pluginId); + } + + return `${this.externalBaseUrl}/${pluginId}`; + } +} diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index ded23b3374..1940773765 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -27,6 +27,7 @@ import { readHttpServerOptions } from '@backstage/backend-app-api'; * resolved to the same host, so there won't be any balancing of internal traffic. * * @public + * @deprecated Use `HostDiscovery` instead */ export class SingleHostDiscovery implements PluginEndpointDiscovery { /** diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts index 5b62d6f4e4..8170409fc1 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -15,4 +15,5 @@ */ export { SingleHostDiscovery } from './SingleHostDiscovery'; +export { HostDiscovery } from './HostDiscovery'; export type { PluginEndpointDiscovery } from './types';