From b0565777e98d2864bf1566c0000c4d63a559043d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 24 Sep 2020 23:37:52 +0200 Subject: [PATCH] backend-common: add initial discovery API and simple single host implementation --- .../src/discovery/SingleHostDiscovery.ts | 80 +++++++++++++++++++ .../backend-common/src/discovery/index.ts | 18 +++++ .../backend-common/src/discovery/types.ts | 61 ++++++++++++++ packages/backend-common/src/index.ts | 1 + .../src/service/lib/ServiceBuilderImpl.ts | 2 +- .../backend-common/src/service/lib/config.ts | 12 ++- 6 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 packages/backend-common/src/discovery/SingleHostDiscovery.ts create mode 100644 packages/backend-common/src/discovery/index.ts create mode 100644 packages/backend-common/src/discovery/types.ts diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts new file mode 100644 index 0000000000..1970583e5f --- /dev/null +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { readBaseOptions } from '../service/lib/config'; +import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl'; + +/** + * SingleHostDiscovery 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. + */ +export class SingleHostDiscovery implements PluginEndpointDiscovery { + /** + * Creates a new SingleHostDiscovery 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 basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config.getString('backend.baseUrl'); + + const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions( + 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 = '::1'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new SingleHostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + ) {} + + async getBaseUrl(pluginId: string): Promise { + return `${this.internalBaseUrl}/${pluginId}`; + } + + async getExternalBaseUrl(pluginId: string): Promise { + return `${this.externalBaseUrl}/${pluginId}`; + } +} diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts new file mode 100644 index 0000000000..7fe320c1e5 --- /dev/null +++ b/packages/backend-common/src/discovery/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export { SingleHostDiscovery } from './SingleHostDiscovery'; +export type { PluginEndpointDiscovery } from './types'; diff --git a/packages/backend-common/src/discovery/types.ts b/packages/backend-common/src/discovery/types.ts new file mode 100644 index 0000000000..22eb4b23d4 --- /dev/null +++ b/packages/backend-common/src/discovery/types.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +/** + * The PluginEndpointDiscovery is used to provide a mechanism for backend + * plugins to discover the endpoints for itself or other backend plugins. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be as simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type PluginEndpointDiscovery = { + /** + * Returns the internal HTTP base URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an internal endpoint for the plugin, with + * the shortest route possible. The URL should be used for service-to-service + * communication within a Backstage backend deployment. + * + * This method must always be called just before making a request, as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `catalog` may return something + * like `http://10.1.2.3/api/catalog` + */ + getBaseUrl(pluginId: string): Promise; + + /** + * Returns the external HTTP base backend URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an external endpoint for the plugin, such that + * it is reachable from the Backstage frontend and other external services. The returned + * URL should be usable for example as a callback / webhook URL. + * + * The returned URL should be stable and in general not change unless other static + * or external configuration is changed. Changes should not come as a surprise + * to an operator of the Backstage backend. + * + * For example, asking for the URL for `catalog` may return something + * like `https://backstage.example.com/api/catalog` + */ + getExternalBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c527f5fea1..7074040655 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -16,6 +16,7 @@ export * from './config'; export * from './database'; +export * from './discovery'; export * from './errors'; export * from './logging'; export * from './middleware'; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 10f9ca4d9b..f3bc59335b 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -39,7 +39,7 @@ import { import { createHttpServer, createHttpsServer } from './hostFactory'; import { metricsHandler } from './metrics'; -const DEFAULT_PORT = 7000; +export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index e257bd4111..3b053c7555 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; export type BaseOptions = { @@ -71,7 +71,7 @@ export type CertificateAttributes = { * } * ``` */ -export function readBaseOptions(config: ConfigReader): BaseOptions { +export function readBaseOptions(config: Config): BaseOptions { if (typeof config.get('listen') === 'string') { // TODO(freben): Expand this to support more addresses and perhaps optional const { host, port } = parseListenAddress(config.getString('listen')); @@ -105,7 +105,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { * } * ``` */ -export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { +export function readCorsOptions(config: Config): CorsOptions | undefined { const cc = config.getOptionalConfig('cors'); if (!cc) { return undefined; @@ -138,9 +138,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { * } * ``` */ -export function readHttpsSettings( - config: ConfigReader, -): HttpsSettings | undefined { +export function readHttpsSettings(config: Config): HttpsSettings | undefined { const cc = config.getOptionalConfig('https'); if (!cc) { @@ -157,7 +155,7 @@ export function readHttpsSettings( } function getOptionalStringOrStrings( - config: ConfigReader, + config: Config, key: string, ): string | string[] | undefined { const value = config.getOptional(key);