backend-common: move HostDiscovery to backend-app-api

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-29 17:45:37 +02:00
parent a1ac0ed0fa
commit 74491c9602
16 changed files with 217 additions and 161 deletions
@@ -0,0 +1,257 @@
/*
* 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('strips trailing slashes in 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 targets from config if provided', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: {
internal: 'http://catalog-backend-internal:8080/api/catalog',
external: 'http://catalog-backend-external:8080/api/catalog',
},
plugins: ['catalog'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('catalog')).resolves.toBe(
'http://catalog-backend-internal:8080/api/catalog',
);
await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe(
'http://catalog-backend-external:8080/api/catalog',
);
});
it('uses a single target for internal and external for a plugin', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: 'http://catalog-backend:8080/api/catalog',
plugins: ['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',
);
});
it('defaults to the backend baseUrl when there is not an endpoint for a plugin', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: 'http://catalog-backend:8080/api/catalog',
plugins: ['catalog'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe(
'http://localhost:80/api/scaffolder',
);
await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe(
'http://localhost:40/api/scaffolder',
);
});
it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: 'http://common-backend:8080/api/{{pluginId}}',
plugins: ['catalog', 'docs'],
},
{
target: {
internal: 'http://scaffolder-internal:8080/api/{{ pluginId }}',
external: 'http://scaffolder-external:8080/api/{{ pluginId }}',
},
plugins: ['scaffolder'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('catalog')).resolves.toBe(
'http://common-backend:8080/api/catalog',
);
await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe(
'http://common-backend:8080/api/catalog',
);
await expect(discovery.getBaseUrl('docs')).resolves.toBe(
'http://common-backend:8080/api/docs',
);
await expect(discovery.getExternalBaseUrl('docs')).resolves.toBe(
'http://common-backend:8080/api/docs',
);
await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe(
'http://scaffolder-internal:8080/api/scaffolder',
);
await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe(
'http://scaffolder-external:8080/api/scaffolder',
);
});
it('encodes the pluginId', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: 'http://common-backend:8080/api/{{pluginId}}',
plugins: ['plugin/beta'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('plugin/beta')).resolves.toBe(
'http://common-backend:8080/api/plugin%2Fbeta',
);
await expect(discovery.getBaseUrl('plugin/alpha')).resolves.toBe(
'http://localhost:80/api/plugin%2Falpha',
);
await expect(discovery.getExternalBaseUrl('plugin/alpha')).resolves.toBe(
'http://localhost:40/api/plugin%2Falpha',
);
});
});
@@ -0,0 +1,132 @@
/*
* 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 { Config } from '@backstage/config';
import { readHttpServerOptions } from '@backstage/backend-app-api';
import { DiscoveryService } from '@backstage/backend-plugin-api';
type Target = string | { internal: string; external: string };
/**
* HostDiscovery is a basic PluginEndpointDiscovery 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.
*
* @public
*/
export class HostDiscovery implements DiscoveryService {
/**
* 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 in config by providing a target and corresponding plugins in `discovery.endpoints`.
* eg.
* ```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]
* ```
*
* 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')
.replace(/\/+$/, '');
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('discovery'),
);
}
private constructor(
private readonly internalBaseUrl: string,
private readonly externalBaseUrl: string,
private readonly discoveryConfig: Config | undefined,
) {}
private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') {
const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints');
const target = endpoints
?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId))
?.get<Target>('target');
if (!target) {
const baseUrl =
type === 'external' ? this.externalBaseUrl : this.internalBaseUrl;
return `${baseUrl}/${encodeURIComponent(pluginId)}`;
}
if (typeof target === 'string') {
return target.replace(
/\{\{\s*pluginId\s*\}\}/g,
encodeURIComponent(pluginId),
);
}
return target[type].replace(
/\{\{\s*pluginId\s*\}\}/g,
encodeURIComponent(pluginId),
);
}
async getBaseUrl(pluginId: string): Promise<string> {
return this.getTargetFromConfig(pluginId, 'internal');
}
async getExternalBaseUrl(pluginId: string): Promise<string> {
return this.getTargetFromConfig(pluginId, 'external');
}
}
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import { HostDiscovery } from '@backstage/backend-common';
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { HostDiscovery } from './HostDiscovery';
/** @public */
export const discoveryServiceFactory = createServiceFactory({
@@ -15,3 +15,4 @@
*/
export { discoveryServiceFactory } from './discoveryServiceFactory';
export { HostDiscovery } from './HostDiscovery';