split service locator and cluster locators (#2900)

* split service locator and cluster locators

* fix test
This commit is contained in:
Matthew Clarke
2020-10-14 15:46:11 +01:00
committed by GitHub
parent 179a960b15
commit 0ede39fd08
15 changed files with 325 additions and 81 deletions
+3 -1
View File
@@ -60,7 +60,9 @@ lighthouse:
baseUrl: http://localhost:3003
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters: []
integrations:
+17 -5
View File
@@ -8,21 +8,33 @@ It responds to Kubernetes requests from the frontend.
## Configuration
### clusterLocatorMethod
### serviceLocatorMethod
This configures how to determine which clusters a component is running in.
Currently, the only valid locator method is:
Currently, the only valid serviceLocatorMethod is:
#### configMultiTenant
#### multiTenant
This configuration assumes that all components run on all the provided clusters.
### clusterLocatorMethods
This is used to determine where to retrieve cluster configuration from.
Currently, the only valid serviceLocatorMethod is:
#### config
This clusterLocatorMethod will read cluster information in from config
Example:
```yaml
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
@@ -35,7 +47,7 @@ kubernetes:
##### clusters
Used by the `configMultiTenant` `clusterLocatorMethod` to construct Kubernetes clients.
Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients.
###### url
@@ -27,7 +27,9 @@ Update `app-config.development.yaml` as follows.
```yaml
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: <KUBERNETES MASTER BASE URL FROM STEP 2>
name: minikube
@@ -15,10 +15,10 @@
*/
import '@backstage/backend-common';
import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator';
import { ConfigReader, Config } from '@backstage/config';
import { ConfigClusterLocator } from './ConfigClusterLocator';
describe('MultiTenantConfigClusterLocator', () => {
describe('ConfigClusterLocator', () => {
it('empty clusters returns empty cluster details', async () => {
const config: Config = new ConfigReader(
{
@@ -27,11 +27,11 @@ describe('MultiTenantConfigClusterLocator', () => {
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
const sut = ConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
const result = await sut.getClusters();
expect(result).toStrictEqual([]);
});
@@ -50,11 +50,11 @@ describe('MultiTenantConfigClusterLocator', () => {
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
const sut = ConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
const result = await sut.getClusters();
expect(result).toStrictEqual([
{
@@ -86,11 +86,11 @@ describe('MultiTenantConfigClusterLocator', () => {
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
const sut = ConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
const result = await sut.getClusters();
expect(result).toStrictEqual([
{
@@ -14,23 +14,20 @@
* limitations under the License.
*/
import { ClusterDetails, KubernetesClustersSupplier } from '..';
import { Config } from '@backstage/config';
import { ClusterDetails, KubernetesClusterLocator } from '..';
// This cluster locator assumes that every service is located on every cluster
// Therefore it will always return all clusters in an app configuration file
export class MultiTenantConfigClusterLocator
implements KubernetesClusterLocator {
export class ConfigClusterLocator implements KubernetesClustersSupplier {
private readonly clusterDetails: ClusterDetails[];
constructor(clusterDetails: ClusterDetails[]) {
this.clusterDetails = clusterDetails;
}
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
static fromConfig(config: Config[]): ConfigClusterLocator {
// TODO: Add validation that authProvider is required and serviceAccountToken
// is required if authProvider is serviceAccount
return new MultiTenantConfigClusterLocator(
return new ConfigClusterLocator(
config.map(c => {
return {
name: c.getString('name'),
@@ -42,9 +39,7 @@ export class MultiTenantConfigClusterLocator
);
}
// As this implementation always returns all clusters serviceId is ignored here
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getClusterByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
async getClusters(): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
}
@@ -0,0 +1,68 @@
/*
* 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, ConfigReader } from '../../../../packages/config/src';
import { getCombinedClusterDetails } from './index';
describe('getCombinedClusterDetails', () => {
it('should retrieve cluster details from config', async () => {
const config: Config = new ConfigReader(
{
kubernetes: {
clusters: [
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
},
],
},
},
'ctx',
);
const result = await getCombinedClusterDetails(['config'], config);
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'google',
},
]);
});
it('throws an error when using an unsupported cluster locator', async () => {
await expect(
getCombinedClusterDetails(['magic' as any], new ConfigReader({}, 'ctx')),
).rejects.toStrictEqual(
new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'),
);
});
});
@@ -0,0 +1,47 @@
/*
* 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 { ClusterDetails, ClusterLocatorMethod } from '..';
import { Config } from '../../../../packages/config/src';
import { ConfigClusterLocator } from './ConfigClusterLocator';
export { ConfigClusterLocator } from './ConfigClusterLocator';
export const getCombinedClusterDetails = async (
clusterLocatorMethods: ClusterLocatorMethod[],
rootConfig: Config,
): Promise<ClusterDetails[]> => {
return Promise.all(
clusterLocatorMethods.map(clusterLocatorMethod => {
switch (clusterLocatorMethod) {
case 'config':
return ConfigClusterLocator.fromConfig(
rootConfig.getConfigArray('kubernetes.clusters'),
).getClusters();
default:
throw new Error(
`Unsupported kubernetes.clusterLocatorMethods: "${clusterLocatorMethod}"`,
);
}
}),
)
.then(res => {
return res.flat();
})
.catch(e => {
throw e;
});
};
@@ -1,18 +0,0 @@
/*
* 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 type ClusterLocatorMethod = 'configMultiTenant' | 'http';
export type AuthProviderType = 'google' | 'serviceAccount';
@@ -0,0 +1,82 @@
/*
* 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 '@backstage/backend-common';
import { MultiTenantServiceLocator } from './MultiTenantServiceLocator';
describe('MultiTenantConfigClusterLocator', () => {
it('empty clusters returns empty cluster details', async () => {
const sut = new MultiTenantServiceLocator([]);
const result = await sut.getClustersByServiceId('ignored');
expect(result).toStrictEqual([]);
});
it('one clusters returns one cluster details', async () => {
const sut = new MultiTenantServiceLocator([
{
name: 'cluster1',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
serviceAccountToken: '12345',
},
]);
const result = await sut.getClustersByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: '12345',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
]);
});
it('two clusters returns two cluster details', async () => {
const sut = new MultiTenantServiceLocator([
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
},
]);
const result = await sut.getClustersByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
url: 'http://localhost:8081',
authProvider: 'google',
},
]);
});
});
@@ -0,0 +1,33 @@
/*
* 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 { ClusterDetails, KubernetesServiceLocator } from '..';
// This locator assumes that every service is located on every cluster
// Therefore it will always return all clusters provided
export class MultiTenantServiceLocator implements KubernetesServiceLocator {
private readonly clusterDetails: ClusterDetails[];
constructor(clusterDetails: ClusterDetails[]) {
this.clusterDetails = clusterDetails;
}
// As this implementation always returns all clusters serviceId is ignored here
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getClustersByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
}
@@ -22,7 +22,7 @@ const TEST_SERVICE_ID = 'my-service';
const fetchObjectsByServiceId = jest.fn();
const getClusterByServiceId = jest.fn();
const getClustersByServiceId = jest.fn();
const mockFetch = (mock: jest.Mock) => {
mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) =>
@@ -70,7 +70,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
});
it('retrieve objects for one cluster', async () => {
getClusterByServiceId.mockImplementation(() =>
getClustersByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
@@ -87,13 +87,13 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
getClustersByServiceId,
},
getVoidLogger(),
{},
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(getClustersByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(1);
expect(result).toStrictEqual({
items: [
@@ -140,7 +140,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
});
it('retrieve objects for two clusters', async () => {
getClusterByServiceId.mockImplementation(() =>
getClustersByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
@@ -161,7 +161,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
getClustersByServiceId,
},
getVoidLogger(),
{
@@ -171,7 +171,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
},
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(getClustersByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(2);
expect(result).toStrictEqual({
items: [
@@ -18,7 +18,7 @@ import { Logger } from 'winston';
import {
AuthRequestBody,
ClusterDetails,
KubernetesClusterLocator,
KubernetesServiceLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByServiceIdResponse,
@@ -29,7 +29,7 @@ import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator
export type GetKubernetesObjectsByServiceIdHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
serviceLocator: KubernetesServiceLocator,
logger: Logger,
requestBody: AuthRequestBody,
objectsToFetch?: Set<KubernetesObjectTypes>,
@@ -49,12 +49,12 @@ const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async (
serviceId,
fetcher,
clusterLocator,
serviceLocator,
logger,
requestBody,
objectsToFetch = DEFAULT_OBJECTS,
) => {
const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId(
const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId(
serviceId,
);
@@ -19,7 +19,7 @@ import express from 'express';
import request from 'supertest';
import { makeRouter } from './router';
import {
KubernetesClusterLocator,
KubernetesServiceLocator,
KubernetesFetcher,
ObjectsByServiceIdResponse,
} from '..';
@@ -27,7 +27,7 @@ import {
describe('router', () => {
let app: express.Express;
let kubernetesFetcher: jest.Mocked<KubernetesFetcher>;
let kubernetesClusterLocator: jest.Mocked<KubernetesClusterLocator>;
let kubernetesServiceLocator: jest.Mocked<KubernetesServiceLocator>;
let handleGetByServiceId: jest.Mock<Promise<ObjectsByServiceIdResponse>>;
beforeAll(async () => {
@@ -35,8 +35,8 @@ describe('router', () => {
fetchObjectsByServiceId: jest.fn(),
};
kubernetesClusterLocator = {
getClusterByServiceId: jest.fn(),
kubernetesServiceLocator = {
getClustersByServiceId: jest.fn(),
};
handleGetByServiceId = jest.fn();
@@ -44,7 +44,7 @@ describe('router', () => {
const router = makeRouter(
getVoidLogger(),
kubernetesFetcher,
kubernetesClusterLocator,
kubernetesServiceLocator,
handleGetByServiceId as any,
);
app = express().use(router);
@@ -18,8 +18,7 @@ import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { ClusterLocatorMethod } from '../cluster-locator/types';
import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator';
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import {
@@ -28,30 +27,35 @@ import {
} from './getKubernetesObjectsByServiceIdHandler';
import {
AuthRequestBody,
KubernetesClusterLocator,
KubernetesServiceLocator,
KubernetesFetcher,
} from '../types/types';
ServiceLocatorMethod,
ClusterLocatorMethod,
ClusterDetails,
} from '..';
import { getCombinedClusterDetails } from '../cluster-locator';
export interface RouterOptions {
logger: Logger;
config: Config;
}
const getClusterLocator = (config: Config): KubernetesClusterLocator => {
const clusterLocatorMethod = config.getString(
'kubernetes.clusterLocatorMethod',
) as ClusterLocatorMethod;
const getServiceLocator = (
config: Config,
clusterDetails: ClusterDetails[],
): KubernetesServiceLocator => {
const serviceLocatorMethod = config.getString(
'kubernetes.serviceLocatorMethod',
) as ServiceLocatorMethod;
switch (clusterLocatorMethod) {
case 'configMultiTenant':
return MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('kubernetes.clusters'),
);
switch (serviceLocatorMethod) {
case 'multiTenant':
return new MultiTenantServiceLocator(clusterDetails);
case 'http':
throw new Error('not implemented');
default:
throw new Error(
`Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`,
`Unsupported kubernetes.clusterLocatorMethod "${serviceLocatorMethod}"`,
);
}
};
@@ -59,13 +63,12 @@ const getClusterLocator = (config: Config): KubernetesClusterLocator => {
export const makeRouter = (
logger: Logger,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
serviceLocator: KubernetesServiceLocator,
handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler,
): express.Router => {
const router = Router();
router.use(express.json());
// TODO error handling
router.post('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
const requestBody: AuthRequestBody = req.body;
@@ -73,7 +76,7 @@ export const makeRouter = (
const response = await handleGetByServiceId(
serviceId,
fetcher,
clusterLocator,
serviceLocator,
logger,
requestBody,
);
@@ -96,17 +99,26 @@ export async function createRouter(
logger.info('Initializing Kubernetes backend');
const clusterLocator = getClusterLocator(options.config);
const fetcher = new KubernetesClientBasedFetcher({
kubernetesClientProvider: new KubernetesClientProvider(),
logger,
});
const clusterLocatorMethods = options.config.getStringArray(
'kubernetes.clusterLocatorMethods',
) as ClusterLocatorMethod[];
const clusterDetails = await getCombinedClusterDetails(
clusterLocatorMethods,
options.config,
);
const serviceLocator = getServiceLocator(options.config, clusterDetails);
return makeRouter(
logger,
fetcher,
clusterLocator,
serviceLocator,
handleGetKubernetesObjectsByServiceId,
);
}
+11 -2
View File
@@ -118,8 +118,13 @@ export interface KubernetesFetcher {
}
// Used to locate which cluster(s) a service is running on
export interface KubernetesClusterLocator {
getClusterByServiceId(serviceId: string): Promise<ClusterDetails[]>;
export interface KubernetesServiceLocator {
getClustersByServiceId(serviceId: string): Promise<ClusterDetails[]>;
}
// Used to load cluster details from different sources
export interface KubernetesClustersSupplier {
getClusters(): Promise<ClusterDetails[]>;
}
export type KubernetesErrorTypes =
@@ -132,3 +137,7 @@ export interface KubernetesFetchError {
statusCode?: number;
resourcePath?: string;
}
export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http
export type ClusterLocatorMethod = 'config';
export type AuthProviderType = 'google' | 'serviceAccount';