replace kubernetes fanout request function with class

This commit is contained in:
mclarke
2021-02-10 11:16:37 +00:00
parent c9e4ce51e1
commit 888497b55c
5 changed files with 183 additions and 203 deletions
@@ -14,11 +14,9 @@
* limitations under the License.
*/
import { handleGetKubernetesObjectsForService } from './getKubernetesObjectsForServiceHandler';
import { getVoidLogger } from '@backstage/backend-common';
import { ObjectFetchParams } from '..';
const TEST_SERVICE_ID = 'my-service';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
const fetchObjectsForService = jest.fn();
@@ -81,34 +79,34 @@ describe('handleGetKubernetesObjectsForService', () => {
mockFetch(fetchObjectsForService);
const result = await handleGetKubernetesObjectsForService(
TEST_SERVICE_ID,
const sut = new KubernetesFanOutHandler(
getVoidLogger(),
{
fetchObjectsForService: fetchObjectsForService,
fetchObjectsForService,
},
{
getClustersByServiceId,
},
getVoidLogger(),
{
entity: {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'test-component',
annotations: {
'backstage.io/kubernetes-labels-selector':
'backstage.io/test-label=test-component',
},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'joe',
);
const result = await sut.getKubernetesObjectsByEntity({
entity: {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'test-component',
annotations: {
'backstage.io/kubernetes-labels-selector':
'backstage.io/test-label=test-component',
},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'joe',
},
},
);
});
expect(getClustersByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsForService.mock.calls.length).toBe(1);
@@ -124,7 +122,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
name: 'my-pods-test-component-test-cluster',
},
},
],
@@ -134,7 +132,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
name: 'my-configmaps-test-component-test-cluster',
},
},
],
@@ -144,7 +142,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
name: 'my-services-test-component-test-cluster',
},
},
],
@@ -172,37 +170,37 @@ describe('handleGetKubernetesObjectsForService', () => {
mockFetch(fetchObjectsForService);
const result = await handleGetKubernetesObjectsForService(
TEST_SERVICE_ID,
const sut = new KubernetesFanOutHandler(
getVoidLogger(),
{
fetchObjectsForService: fetchObjectsForService,
fetchObjectsForService,
},
{
getClustersByServiceId,
},
getVoidLogger(),
{
auth: {
google: 'google_token_123',
);
const result = await sut.getKubernetesObjectsByEntity({
auth: {
google: 'google_token_123',
},
entity: {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'test-component',
annotations: {
'backstage.io/kubernetes-labels-selector':
'backstage.io/test-label=test-component',
},
},
entity: {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'test-component',
annotations: {
'backstage.io/kubernetes-labels-selector':
'backstage.io/test-label=test-component',
},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'joe',
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'joe',
},
},
);
});
expect(getClustersByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsForService.mock.calls.length).toBe(2);
@@ -218,7 +216,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
name: 'my-pods-test-component-test-cluster',
},
},
],
@@ -228,7 +226,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
name: 'my-configmaps-test-component-test-cluster',
},
},
],
@@ -238,7 +236,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
name: 'my-services-test-component-test-cluster',
},
},
],
@@ -256,7 +254,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-pods-my-service-other-cluster',
name: 'my-pods-test-component-other-cluster',
},
},
],
@@ -266,7 +264,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-configmaps-my-service-other-cluster',
name: 'my-configmaps-test-component-other-cluster',
},
},
],
@@ -276,7 +274,7 @@ describe('handleGetKubernetesObjectsForService', () => {
resources: [
{
metadata: {
name: 'my-services-my-service-other-cluster',
name: 'my-services-test-component-other-cluster',
},
},
],
@@ -0,0 +1,109 @@
/*
* Copyright 2021 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 { Logger } from 'winston';
import {
ClusterDetails,
KubernetesFetcher,
KubernetesObjectTypes,
KubernetesRequestBody,
KubernetesServiceLocator,
} from '../types/types';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
'pods',
'services',
'configmaps',
'deployments',
'replicasets',
'horizontalpodautoscalers',
'ingresses',
]);
export class KubernetesFanOutHandler {
private readonly logger: Logger;
private readonly fetcher: KubernetesFetcher;
private readonly serviceLocator: KubernetesServiceLocator;
constructor(
logger: Logger,
fetcher: KubernetesFetcher,
serviceLocator: KubernetesServiceLocator,
) {
this.logger = logger;
this.fetcher = fetcher;
this.serviceLocator = serviceLocator;
}
async getKubernetesObjectsByEntity(
requestBody: KubernetesRequestBody,
objectTypesToFetch: Set<KubernetesObjectTypes> = DEFAULT_OBJECTS,
) {
const entityName = requestBody.entity.metadata.name;
const clusterDetails: ClusterDetails[] = await this.serviceLocator.getClustersByServiceId(
entityName,
);
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
const promises: Promise<ClusterDetails>[] = clusterDetails.map(cd => {
const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
cd.authProvider,
);
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
cd,
requestBody,
);
});
const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all(
promises,
);
this.logger.info(
`entity.metadata.name=${entityName} clusterDetails=[${clusterDetailsDecoratedForAuth
.map(c => c.name)
.join(', ')}]`,
);
const labelSelector: string =
requestBody.entity?.metadata?.annotations?.[
'backstage.io/kubernetes-label-selector'
] || `backstage.io/kubernetes-id=${entityName}`;
return Promise.all(
clusterDetailsDecoratedForAuth.map(clusterDetailsItem => {
return this.fetcher
.fetchObjectsForService({
serviceId: entityName,
clusterDetails: clusterDetailsItem,
objectTypesToFetch,
labelSelector,
})
.then(result => {
return {
cluster: {
name: clusterDetailsItem.name,
},
resources: result.responses,
errors: result.errors,
};
});
}),
).then(r => ({ items: r }));
}
}
@@ -1,107 +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.
*/
import { Logger } from 'winston';
import {
KubernetesRequestBody,
ClusterDetails,
KubernetesServiceLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByEntityResponse,
ObjectFetchParams,
} from '../types/types';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
export type GetKubernetesObjectsForServiceHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
serviceLocator: KubernetesServiceLocator,
logger: Logger,
requestBody: KubernetesRequestBody,
objectTypesToFetch?: Set<KubernetesObjectTypes>,
) => Promise<ObjectsByEntityResponse>;
const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
'pods',
'services',
'configmaps',
'deployments',
'replicasets',
'horizontalpodautoscalers',
'ingresses',
]);
// Fans out the request to all clusters that the service lives in, aggregates their responses together
export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServiceHandler = async (
serviceId,
fetcher,
serviceLocator,
logger,
requestBody,
objectTypesToFetch = DEFAULT_OBJECTS,
) => {
const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId(
serviceId,
);
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
const promises: Promise<ClusterDetails>[] = clusterDetails.map(cd => {
const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
cd.authProvider,
);
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
cd,
requestBody,
);
});
const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all(
promises,
);
logger.info(
`serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth
.map(c => c.name)
.join(', ')}]`,
);
const labelSelector: string =
requestBody.entity?.metadata?.annotations?.[
'backstage.io/kubernetes-label-selector'
] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`;
return Promise.all(
clusterDetailsDecoratedForAuth.map(clusterDetailsItem => {
return fetcher
.fetchObjectsForService({
serviceId,
clusterDetails: clusterDetailsItem,
objectTypesToFetch,
labelSelector,
} as ObjectFetchParams)
.then(result => {
return {
cluster: {
name: clusterDetailsItem.name,
},
resources: result.responses,
errors: result.errors,
};
});
}),
).then(r => ({ items: r }));
};
@@ -18,35 +18,18 @@ import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { makeRouter } from './router';
import {
KubernetesServiceLocator,
KubernetesFetcher,
ObjectsByEntityResponse,
} from '..';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
describe('router', () => {
let app: express.Express;
let kubernetesFetcher: jest.Mocked<KubernetesFetcher>;
let kubernetesServiceLocator: jest.Mocked<KubernetesServiceLocator>;
let handleGetByServiceId: jest.Mock<Promise<ObjectsByEntityResponse>>;
let kubernetesFanOutHandler: jest.Mocked<KubernetesFanOutHandler>;
beforeAll(async () => {
kubernetesFetcher = {
fetchObjectsForService: jest.fn(),
};
kubernetesFanOutHandler = {
getKubernetesObjectsByEntity: jest.fn(),
} as any;
kubernetesServiceLocator = {
getClustersByServiceId: jest.fn(),
};
handleGetByServiceId = jest.fn();
const router = makeRouter(
getVoidLogger(),
kubernetesFetcher,
kubernetesServiceLocator,
handleGetByServiceId as any,
);
const router = makeRouter(getVoidLogger(), kubernetesFanOutHandler);
app = express().use(router);
});
@@ -67,7 +50,9 @@ describe('router', () => {
],
},
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockReturnValueOnce(
Promise.resolve(result),
);
const response = await request(app).post('/services/test-service');
@@ -87,7 +72,9 @@ describe('router', () => {
],
},
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockReturnValueOnce(
Promise.resolve(result),
);
const response = await request(app)
.post('/services/test-service')
@@ -103,7 +90,9 @@ describe('router', () => {
});
it('internal error: lists kubernetes objects', async () => {
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
kubernetesFanOutHandler.getKubernetesObjectsByEntity.mockRejectedValue(
Error('some internal error'),
);
const response = await request(app).post('/services/test-service');
@@ -21,19 +21,15 @@ import { Config } from '@backstage/config';
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import {
GetKubernetesObjectsForServiceHandler,
handleGetKubernetesObjectsForService,
} from './getKubernetesObjectsForServiceHandler';
import {
KubernetesRequestBody,
KubernetesServiceLocator,
KubernetesFetcher,
ServiceLocatorMethod,
ClusterLocatorMethod,
ClusterDetails,
} from '..';
import { getCombinedClusterDetails } from '../cluster-locator';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
export interface RouterOptions {
logger: Logger;
@@ -62,9 +58,7 @@ const getServiceLocator = (
export const makeRouter = (
logger: Logger,
fetcher: KubernetesFetcher,
serviceLocator: KubernetesServiceLocator,
handleGetByEntity: GetKubernetesObjectsForServiceHandler,
kubernetesFanOutHandler: KubernetesFanOutHandler,
): express.Router => {
const router = Router();
router.use(express.json());
@@ -73,11 +67,7 @@ export const makeRouter = (
const serviceId = req.params.serviceId;
const requestBody: KubernetesRequestBody = req.body;
try {
const response = await handleGetByEntity(
serviceId,
fetcher,
serviceLocator,
logger,
const response = await kubernetesFanOutHandler.getKubernetesObjectsByEntity(
requestBody,
);
res.send(response);
@@ -115,10 +105,11 @@ export async function createRouter(
const serviceLocator = getServiceLocator(options.config, clusterDetails);
return makeRouter(
const kubernetesFanOutHandler = new KubernetesFanOutHandler(
logger,
fetcher,
serviceLocator,
handleGetKubernetesObjectsForService,
);
return makeRouter(logger, kubernetesFanOutHandler);
}