feat: Carlos Lopez pre RFC update commit squash
docs: Remove unnecessary types test: Use MSW instead of mocking fetch fix: Updated api-report.md & error stringify from @backstage/errors feat: Kubernetes builder getters/setters update fix: Error handling with fix: Error handling with`@backstage/errors` fix: Use same MSW version as other plugins fix: Sort handleProxyRequest inputs fix: Small type & comment fixes fix: Change serverIP to serverURI fix: Variable name improvements Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com>
This commit is contained in:
committed by
Fredrik Adelöw
parent
b585179770
commit
e862980c57
@@ -22,6 +22,8 @@ import { Logger } from 'winston';
|
||||
import { Metrics } from '@kubernetes/client-node';
|
||||
import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { PodStatus } from '@kubernetes/client-node/dist/top';
|
||||
import type { Request as Request_2 } from 'express';
|
||||
import { TokenCredential } from '@azure/identity';
|
||||
|
||||
// @alpha (undocumented)
|
||||
@@ -188,6 +190,8 @@ export class KubernetesBuilder {
|
||||
options: KubernetesObjectsProviderOptions,
|
||||
): KubernetesObjectsProvider;
|
||||
// (undocumented)
|
||||
protected buildProxy(): KubernetesProxy;
|
||||
// (undocumented)
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
@@ -207,9 +211,19 @@ export class KubernetesBuilder {
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): Promise<ClusterDetails[]>;
|
||||
// (undocumented)
|
||||
protected getClusterSupplier(): KubernetesClustersSupplier;
|
||||
// (undocumented)
|
||||
protected getFetcher(): KubernetesFetcher;
|
||||
// (undocumented)
|
||||
protected getObjectsProvider(
|
||||
options: KubernetesObjectsProviderOptions,
|
||||
): KubernetesObjectsProvider;
|
||||
// (undocumented)
|
||||
protected getObjectTypesToFetch(): ObjectToFetch[] | undefined;
|
||||
// (undocumented)
|
||||
protected getProxyServices(): KubernetesProxyServices;
|
||||
protected getProxy(): KubernetesProxy;
|
||||
// (undocumented)
|
||||
protected getServiceLocator(): KubernetesServiceLocator;
|
||||
// (undocumented)
|
||||
protected getServiceLocatorMethod(): ServiceLocatorMethod;
|
||||
// (undocumented)
|
||||
@@ -226,6 +240,8 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this;
|
||||
// (undocumented)
|
||||
setProxy(proxy?: KubernetesProxy): this;
|
||||
// (undocumented)
|
||||
setServiceLocator(serviceLocator?: KubernetesServiceLocator): this;
|
||||
}
|
||||
|
||||
@@ -330,9 +346,25 @@ export type KubernetesObjectTypes =
|
||||
| 'daemonsets';
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface KubernetesProxyServices {
|
||||
export class KubernetesProxy {
|
||||
constructor(logger: Logger);
|
||||
// (undocumented)
|
||||
kcs: KubernetesClustersSupplier;
|
||||
handleProxyRequest(
|
||||
req: Request_2,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): Promise<KubernetesProxyResponse>;
|
||||
// (undocumented)
|
||||
protected readonly logger: Logger;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface KubernetesProxyResponse {
|
||||
// (undocumented)
|
||||
cluster?: string;
|
||||
// (undocumented)
|
||||
code: number;
|
||||
// (undocumented)
|
||||
data: any;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"dependencies": {
|
||||
"@azure/identity": "^2.0.4",
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
@@ -56,8 +57,8 @@
|
||||
"helmet": "^6.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"stream-buffers": "^3.0.2",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
@@ -66,6 +67,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/aws4": "^1.5.1",
|
||||
"aws-sdk-mock": "^5.2.1",
|
||||
"msw": "^0.48.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from './kubernetes-auth-translator/types';
|
||||
export * from './service/router';
|
||||
export * from './service/KubernetesBuilder';
|
||||
export * from './service/KubernetesClientProvider';
|
||||
export * from './service/KubernetesProxy';
|
||||
|
||||
export * from './types/types';
|
||||
|
||||
|
||||
@@ -13,36 +13,34 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { Duration } from 'luxon';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { getCombinedClusterSupplier } from '../cluster-locator';
|
||||
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
|
||||
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
|
||||
import {
|
||||
KubernetesObjectTypes,
|
||||
ServiceLocatorMethod,
|
||||
CustomResource,
|
||||
KubernetesObjectsProvider,
|
||||
ObjectsByEntityRequest,
|
||||
KubernetesClustersSupplier,
|
||||
KubernetesFetcher,
|
||||
KubernetesServiceLocator,
|
||||
KubernetesObjectsProvider,
|
||||
KubernetesObjectsProviderOptions,
|
||||
KubernetesProxyServices,
|
||||
KubernetesObjectTypes,
|
||||
KubernetesServiceLocator,
|
||||
ObjectsByEntityRequest,
|
||||
ServiceLocatorMethod,
|
||||
} from '../types/types';
|
||||
import { KubernetesClientProvider } from './KubernetesClientProvider';
|
||||
|
||||
import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy';
|
||||
|
||||
import {
|
||||
DEFAULT_OBJECTS,
|
||||
KubernetesFanOutHandler,
|
||||
} from './KubernetesFanOutHandler';
|
||||
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -80,15 +78,13 @@ export class KubernetesBuilder {
|
||||
private objectsProvider?: KubernetesObjectsProvider;
|
||||
private fetcher?: KubernetesFetcher;
|
||||
private serviceLocator?: KubernetesServiceLocator;
|
||||
private proxy: KubernetesProxy;
|
||||
private proxy?: KubernetesProxy;
|
||||
|
||||
static createBuilder(env: KubernetesEnvironment) {
|
||||
return new KubernetesBuilder(env);
|
||||
}
|
||||
|
||||
constructor(protected readonly env: KubernetesEnvironment) {
|
||||
this.proxy = new KubernetesProxy(env.logger);
|
||||
}
|
||||
constructor(protected readonly env: KubernetesEnvironment) {}
|
||||
|
||||
public async build(): KubernetesBuilderReturn {
|
||||
const logger = this.env.logger;
|
||||
@@ -109,25 +105,19 @@ export class KubernetesBuilder {
|
||||
}
|
||||
const customResources = this.buildCustomResources();
|
||||
|
||||
const fetcher = this.fetcher ?? this.buildFetcher();
|
||||
const fetcher = this.getFetcher();
|
||||
|
||||
const clusterSupplier =
|
||||
this.clusterSupplier ??
|
||||
this.buildClusterSupplier(this.defaultClusterRefreshInterval);
|
||||
const clusterSupplier = this.getClusterSupplier();
|
||||
|
||||
const serviceLocator =
|
||||
this.serviceLocator ??
|
||||
this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier);
|
||||
const serviceLocator = this.getServiceLocator();
|
||||
|
||||
const objectsProvider =
|
||||
this.objectsProvider ??
|
||||
this.buildObjectsProvider({
|
||||
logger,
|
||||
fetcher,
|
||||
serviceLocator,
|
||||
customResources,
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
const objectsProvider = this.getObjectsProvider({
|
||||
logger,
|
||||
fetcher,
|
||||
serviceLocator,
|
||||
customResources,
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
|
||||
const router = this.buildRouter(
|
||||
objectsProvider,
|
||||
@@ -170,6 +160,11 @@ export class KubernetesBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public setProxy(proxy?: KubernetesProxy) {
|
||||
this.proxy = proxy;
|
||||
return this;
|
||||
}
|
||||
|
||||
protected buildCustomResources() {
|
||||
const customResources: CustomResource[] = (
|
||||
this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? []
|
||||
@@ -193,24 +188,29 @@ export class KubernetesBuilder {
|
||||
refreshInterval: Duration,
|
||||
): KubernetesClustersSupplier {
|
||||
const config = this.env.config;
|
||||
return getCombinedClusterSupplier(
|
||||
this.clusterSupplier = getCombinedClusterSupplier(
|
||||
config,
|
||||
this.env.catalogApi,
|
||||
refreshInterval,
|
||||
);
|
||||
|
||||
return this.clusterSupplier;
|
||||
}
|
||||
|
||||
protected buildObjectsProvider(
|
||||
options: KubernetesObjectsProviderOptions,
|
||||
): KubernetesObjectsProvider {
|
||||
return new KubernetesFanOutHandler(options);
|
||||
this.objectsProvider = new KubernetesFanOutHandler(options);
|
||||
return this.objectsProvider;
|
||||
}
|
||||
|
||||
protected buildFetcher(): KubernetesFetcher {
|
||||
return new KubernetesClientBasedFetcher({
|
||||
this.fetcher = new KubernetesClientBasedFetcher({
|
||||
kubernetesClientProvider: new KubernetesClientProvider(),
|
||||
logger: this.env.logger,
|
||||
});
|
||||
|
||||
return this.fetcher;
|
||||
}
|
||||
|
||||
protected buildServiceLocator(
|
||||
@@ -219,14 +219,19 @@ export class KubernetesBuilder {
|
||||
): KubernetesServiceLocator {
|
||||
switch (method) {
|
||||
case 'multiTenant':
|
||||
return this.buildMultiTenantServiceLocator(clusterSupplier);
|
||||
this.serviceLocator =
|
||||
this.buildMultiTenantServiceLocator(clusterSupplier);
|
||||
break;
|
||||
case 'http':
|
||||
return this.buildHttpServiceLocator(clusterSupplier);
|
||||
this.serviceLocator = this.buildHttpServiceLocator(clusterSupplier);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported kubernetes.clusterLocatorMethod "${method}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.serviceLocator;
|
||||
}
|
||||
|
||||
protected buildMultiTenantServiceLocator(
|
||||
@@ -241,6 +246,11 @@ export class KubernetesBuilder {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
protected buildProxy(): KubernetesProxy {
|
||||
this.proxy = new KubernetesProxy(this.env.logger);
|
||||
return this.proxy;
|
||||
}
|
||||
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
@@ -250,6 +260,8 @@ export class KubernetesBuilder {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
const proxy = this.getProxy();
|
||||
|
||||
// @deprecated
|
||||
router.post('/services/:serviceId', async (req, res) => {
|
||||
const serviceId = req.params.serviceId;
|
||||
@@ -280,11 +292,13 @@ export class KubernetesBuilder {
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.post('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.put('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.patch('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.delete('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
if (typeof proxy?.handleProxyRequest === 'function') {
|
||||
router.get('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.post('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.put('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.patch('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
router.delete('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
|
||||
}
|
||||
|
||||
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
|
||||
|
||||
@@ -309,6 +323,31 @@ export class KubernetesBuilder {
|
||||
) as ServiceLocatorMethod;
|
||||
}
|
||||
|
||||
protected getFetcher(): KubernetesFetcher {
|
||||
return this.fetcher ?? this.buildFetcher();
|
||||
}
|
||||
|
||||
protected getClusterSupplier() {
|
||||
return (
|
||||
this.clusterSupplier ??
|
||||
this.buildClusterSupplier(this.defaultClusterRefreshInterval)
|
||||
);
|
||||
}
|
||||
|
||||
protected getServiceLocator(): KubernetesServiceLocator {
|
||||
return (
|
||||
this.serviceLocator ??
|
||||
this.buildServiceLocator(
|
||||
this.getServiceLocatorMethod(),
|
||||
this.getClusterSupplier(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected getObjectsProvider(options: KubernetesObjectsProviderOptions) {
|
||||
return this.objectsProvider ?? this.buildObjectsProvider(options);
|
||||
}
|
||||
|
||||
protected getObjectTypesToFetch() {
|
||||
const objectTypesToFetchStrings = this.env.config.getOptionalStringArray(
|
||||
'kubernetes.objectTypes',
|
||||
@@ -339,27 +378,27 @@ export class KubernetesBuilder {
|
||||
return objectTypesToFetch;
|
||||
}
|
||||
|
||||
protected getProxy() {
|
||||
return this.proxy ?? this.buildProxy();
|
||||
}
|
||||
|
||||
protected async makeProxyRequest(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
) {
|
||||
const services = this.getProxyServices();
|
||||
const supplier = this.getClustersSupplier();
|
||||
const proxy = this.getProxy();
|
||||
|
||||
const proxyResponse: KubernetesProxyResponse =
|
||||
await this.proxy.handleProxyRequest(services, req);
|
||||
await proxy.handleProxyRequest(req, supplier);
|
||||
|
||||
res.status(proxyResponse.code).json(proxyResponse.data);
|
||||
}
|
||||
|
||||
protected getProxyServices(): KubernetesProxyServices {
|
||||
const kcs =
|
||||
private getClustersSupplier(): KubernetesClustersSupplier {
|
||||
return (
|
||||
this.clusterSupplier ??
|
||||
this.buildClusterSupplier(this.defaultClusterRefreshInterval);
|
||||
|
||||
if (!kcs) {
|
||||
this.env.logger.error('could not find cluster supplier!');
|
||||
}
|
||||
|
||||
return {
|
||||
kcs,
|
||||
};
|
||||
this.buildClusterSupplier(this.defaultClusterRefreshInterval)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,32 +13,27 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import {
|
||||
ClusterDetails,
|
||||
KubernetesClustersSupplier,
|
||||
KubernetesProxyServices,
|
||||
} from '../types/types';
|
||||
import { KubernetesProxy } from './KubernetesProxy';
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
import 'buffer';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
const { Response } = jest.requireActual('node-fetch');
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { Request } from 'express';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
import { KubernetesProxy } from './KubernetesProxy';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
describe('KubernetesProxy', () => {
|
||||
let _clientMock: any;
|
||||
let sut: KubernetesProxy;
|
||||
let proxy: KubernetesProxy;
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
const buildEncodedRequest = (
|
||||
clustersHeader: any,
|
||||
query: string,
|
||||
body?: any,
|
||||
body?: unknown,
|
||||
): Request => {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
const encodedClusters = Buffer.from(
|
||||
@@ -75,41 +70,29 @@ describe('KubernetesProxy', () => {
|
||||
return req;
|
||||
};
|
||||
|
||||
const buildProxyServicesWithClusters = (
|
||||
const buildClustersSupplierWithClusters = (
|
||||
clusters: ClusterDetails[],
|
||||
): KubernetesProxyServices => {
|
||||
const kcs: KubernetesClustersSupplier = {
|
||||
getClusters: async () => {
|
||||
return clusters;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kcs,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
_clientMock = {
|
||||
handleProxyRequest: jest.fn(),
|
||||
};
|
||||
|
||||
sut = new KubernetesProxy(getVoidLogger());
|
||||
): KubernetesClustersSupplier => ({
|
||||
getClusters: async () => {
|
||||
return clusters;
|
||||
},
|
||||
});
|
||||
|
||||
it('should return a 404 if no clusters are found', async () => {
|
||||
const services = buildProxyServicesWithClusters([]);
|
||||
beforeEach(() => {
|
||||
proxy = new KubernetesProxy(getVoidLogger());
|
||||
});
|
||||
|
||||
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
|
||||
const clustersSupplier = buildClustersSupplierWithClusters([]);
|
||||
const req = buildEncodedRequest({}, 'api');
|
||||
|
||||
const result = await sut.handleProxyRequest(services, req);
|
||||
|
||||
expect(result.code).toEqual(404);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
proxy.handleProxyRequest(req, clustersSupplier),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('should match the response code of the Kubernetes response (single cluster)', async () => {
|
||||
const services = buildProxyServicesWithClusters([
|
||||
const clusters: ClusterDetails[] = [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
@@ -117,7 +100,9 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
skipTLSVerify: true,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
|
||||
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
|
||||
|
||||
const apiResponse = {
|
||||
@@ -131,21 +116,19 @@ describe('KubernetesProxy', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// @ts-ignore-next-line
|
||||
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
|
||||
new Response(JSON.stringify(apiResponse), {
|
||||
status: 299,
|
||||
}),
|
||||
worker.use(
|
||||
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(299), ctx.body(JSON.stringify(apiResponse))),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sut.handleProxyRequest(services, req);
|
||||
const result = await proxy.handleProxyRequest(req, clustersSupplier);
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(result.code).toEqual(299);
|
||||
});
|
||||
|
||||
it('should match the response code of the best Kubernetes response (multi cluster)', async () => {
|
||||
const services = buildProxyServicesWithClusters([
|
||||
const clusters: ClusterDetails[] = [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9998',
|
||||
@@ -160,7 +143,9 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
skipTLSVerify: true,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
|
||||
const req = buildEncodedRequest(
|
||||
{ cluster1: 'token', cluster2: 'token' },
|
||||
'api',
|
||||
@@ -187,26 +172,22 @@ describe('KubernetesProxy', () => {
|
||||
code: 401,
|
||||
};
|
||||
|
||||
(fetch as jest.MockedFunction<typeof fetch>)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(apiResponse1), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(apiResponse2), {
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
worker.use(
|
||||
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))),
|
||||
),
|
||||
rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sut.handleProxyRequest(services, req);
|
||||
const result = await proxy.handleProxyRequest(req, clustersSupplier);
|
||||
|
||||
expect(fetch).toBeCalledTimes(2);
|
||||
expect(result.code).toEqual(200);
|
||||
});
|
||||
|
||||
it('should pass the exact response data from Kubernetes (single cluster)', async () => {
|
||||
const services = buildProxyServicesWithClusters([
|
||||
const clusters: ClusterDetails[] = [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9999',
|
||||
@@ -214,7 +195,9 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
skipTLSVerify: true,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
|
||||
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
|
||||
|
||||
const apiResponse = {
|
||||
@@ -228,14 +211,13 @@ describe('KubernetesProxy', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// @ts-ignore-next-line
|
||||
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
|
||||
new Response(JSON.stringify(apiResponse), {
|
||||
status: 200,
|
||||
}),
|
||||
worker.use(
|
||||
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse))),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sut.handleProxyRequest(services, req);
|
||||
const result = await proxy.handleProxyRequest(req, clustersSupplier);
|
||||
|
||||
const resultString = JSON.stringify(result.data);
|
||||
const expectedString = JSON.stringify({
|
||||
@@ -251,12 +233,11 @@ describe('KubernetesProxy', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(1);
|
||||
expect(resultString).toEqual(expectedString);
|
||||
});
|
||||
|
||||
it('should pass the exact response data from Kubernetes (multi cluster)', async () => {
|
||||
const services = buildProxyServicesWithClusters([
|
||||
const clusters: ClusterDetails[] = [
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'http://localhost:9998',
|
||||
@@ -271,7 +252,9 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
skipTLSVerify: true,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
|
||||
const req = buildEncodedRequest(
|
||||
{ cluster1: 'token', cluster2: 'token' },
|
||||
'api',
|
||||
@@ -298,20 +281,16 @@ describe('KubernetesProxy', () => {
|
||||
code: 401,
|
||||
};
|
||||
|
||||
// @ts-ignore-next-line
|
||||
(fetch as jest.MockedFunction<typeof fetch>)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(apiResponse1), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(apiResponse2), {
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
worker.use(
|
||||
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))),
|
||||
),
|
||||
rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
|
||||
res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sut.handleProxyRequest(services, req);
|
||||
const result = await proxy.handleProxyRequest(req, clustersSupplier);
|
||||
|
||||
const resultString = JSON.stringify(result.data);
|
||||
const expectedString = JSON.stringify({
|
||||
@@ -336,7 +315,6 @@ describe('KubernetesProxy', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetch).toBeCalledTimes(2);
|
||||
expect(resultString).toEqual(expectedString);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,69 +13,81 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { KubeConfig, bufferFromFileOrString } from '@kubernetes/client-node';
|
||||
import { Logger } from 'winston';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
ForwardedError,
|
||||
InputError,
|
||||
NotFoundError,
|
||||
stringifyError,
|
||||
} from '@backstage/errors';
|
||||
import { bufferFromFileOrString, KubeConfig } from '@kubernetes/client-node';
|
||||
import * as https from 'https';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ClusterDetails,
|
||||
KubernetesProxyServices,
|
||||
KubernetesClustersSupplier,
|
||||
} from '../types/types';
|
||||
|
||||
const HEADER_CONTENT_TYPE: string = 'Content-Type';
|
||||
const APPLICATION_JSON: string = 'application/json';
|
||||
|
||||
const HEADER_KUBERNETES_CLUSTERS: string = 'X-Kubernetes-Clusters';
|
||||
|
||||
const ERROR_BAD_REQUEST: number = 400;
|
||||
const ERROR_NOT_FOUND: number = 404;
|
||||
const ERROR_INTERNAL_SERVER: number = 500;
|
||||
|
||||
const CLUSTER_USER_NAME: string = 'backstage';
|
||||
|
||||
/**
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface KubernetesProxyResponse {
|
||||
code: number;
|
||||
data: any;
|
||||
cluster?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
interface KubernetesProxyClusters {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export class KubernetesProxy {
|
||||
constructor(protected readonly logger: Logger) {}
|
||||
|
||||
public async handleProxyRequest(
|
||||
services: KubernetesProxyServices,
|
||||
req: Request,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): Promise<KubernetesProxyResponse> {
|
||||
const krc = this.getKubernetesRequestedClusters(req);
|
||||
const requestedClusters = this.getKubernetesRequestedClusters(req);
|
||||
|
||||
if (Object.keys(krc).length < 1) {
|
||||
return {
|
||||
code: ERROR_NOT_FOUND,
|
||||
data: 'No clusters found!',
|
||||
};
|
||||
if (Object.keys(requestedClusters).length < 1) {
|
||||
this.logger.error(`No clusters found`);
|
||||
throw new NotFoundError('No clusters found!');
|
||||
}
|
||||
|
||||
const details = await this.getClusterDetails(services.kcs, krc);
|
||||
const clusterDetails = await this.getClusterDetails(
|
||||
clusterSupplier,
|
||||
requestedClusters,
|
||||
);
|
||||
|
||||
if (details.length < 1) {
|
||||
return {
|
||||
code: ERROR_NOT_FOUND,
|
||||
data: 'No clusters found!',
|
||||
};
|
||||
if (clusterDetails.length < 1) {
|
||||
this.logger.error(`No clusters found`);
|
||||
throw new NotFoundError('No clusters found!');
|
||||
}
|
||||
|
||||
const responses = await Promise.all(
|
||||
details.map(async d => {
|
||||
const response = await this.makeRequestToCluster(d, req);
|
||||
clusterDetails.map(async clusterDetail => {
|
||||
const response = await this.makeRequestToCluster(clusterDetail, req);
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
@@ -116,7 +128,7 @@ export class KubernetesProxy {
|
||||
return clusters;
|
||||
} catch (e: any) {
|
||||
this.logger.debug(
|
||||
`error with encoded cluster header: ${JSON.stringify(e)}`,
|
||||
`error with encoded cluster header: ${stringifyError(e)}`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
@@ -124,17 +136,17 @@ export class KubernetesProxy {
|
||||
|
||||
private async getClusterDetails(
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
krc: KubernetesProxyClusters,
|
||||
requestedClusters: KubernetesProxyClusters,
|
||||
): Promise<ClusterDetails[]> {
|
||||
const clusters = await clusterSupplier.getClusters();
|
||||
|
||||
const clusterNames = Object.keys(krc);
|
||||
const clusterNames = Object.keys(requestedClusters);
|
||||
|
||||
const clusterDetails = clusters.filter(c => clusterNames.includes(c.name));
|
||||
|
||||
const clusterDetailsAuth = clusterDetails.map(c => {
|
||||
const cAuth: ClusterDetails = Object.assign(c, {
|
||||
serviceAccountToken: krc[c.name],
|
||||
serviceAccountToken: requestedClusters[c.name],
|
||||
});
|
||||
return cAuth;
|
||||
});
|
||||
@@ -151,28 +163,26 @@ export class KubernetesProxy {
|
||||
details: ClusterDetails,
|
||||
req: Request,
|
||||
): Promise<KubernetesProxyResponse> {
|
||||
const serverIP = this.getClusterURI(details);
|
||||
if (!serverIP) {
|
||||
return {
|
||||
code: ERROR_INTERNAL_SERVER,
|
||||
data: null,
|
||||
};
|
||||
const serverURI = this.getClusterURI(details);
|
||||
|
||||
if (!serverURI) {
|
||||
this.logger.error(`Cluster ${details.name} details IP error`);
|
||||
|
||||
throw new ConflictError('Cluster detail error');
|
||||
}
|
||||
|
||||
const query = decodeURIComponent(req.params.encodedQuery) || '';
|
||||
const uri = `${serverIP}/${query}`;
|
||||
const uri = `${serverURI}/${query}`;
|
||||
|
||||
const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON;
|
||||
|
||||
const res = await this.sendClusterRequest(
|
||||
return await this.sendClusterRequest(
|
||||
details,
|
||||
uri,
|
||||
req.method,
|
||||
contentType,
|
||||
req.body,
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async sendClusterRequest(
|
||||
@@ -183,16 +193,14 @@ export class KubernetesProxy {
|
||||
body?: any,
|
||||
): Promise<KubernetesProxyResponse> {
|
||||
const bearerToken = details.serviceAccountToken;
|
||||
|
||||
if (!bearerToken) {
|
||||
return {
|
||||
code: ERROR_BAD_REQUEST,
|
||||
data: {
|
||||
error: 'Invalid service account token',
|
||||
},
|
||||
};
|
||||
this.logger.error('Invalid service account token');
|
||||
|
||||
throw new AuthenticationError('Invalid service account token');
|
||||
}
|
||||
|
||||
const reqData: any = {
|
||||
const reqData: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
@@ -205,13 +213,10 @@ export class KubernetesProxy {
|
||||
const ca = bufferFromFileOrString('', details.caData)?.toString() || '';
|
||||
reqData.agent = new https.Agent({ ca });
|
||||
} else {
|
||||
this.logger.info('could not find CA certificate!');
|
||||
return {
|
||||
code: ERROR_INTERNAL_SERVER,
|
||||
data: {
|
||||
error: 'Invalid CA certificate configured within Backstage',
|
||||
},
|
||||
};
|
||||
this.logger.error('could not find CA certificate!');
|
||||
throw new InputError(
|
||||
'Invalid CA certificate configured within Backstage',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,28 +225,25 @@ export class KubernetesProxy {
|
||||
}
|
||||
|
||||
try {
|
||||
const req = await fetch(uri, reqData);
|
||||
const res = await fetch(uri, reqData);
|
||||
|
||||
let data: string | any;
|
||||
|
||||
let res;
|
||||
if (contentType.includes(APPLICATION_JSON)) {
|
||||
res = await req.json();
|
||||
data = await res.json();
|
||||
} else {
|
||||
res = await req.text();
|
||||
data = await res.text();
|
||||
}
|
||||
|
||||
const proxyResponse: KubernetesProxyResponse = {
|
||||
code: req.status,
|
||||
data: res,
|
||||
code: res.status,
|
||||
data,
|
||||
cluster: details.name,
|
||||
};
|
||||
|
||||
return proxyResponse;
|
||||
} catch (e: any) {
|
||||
return {
|
||||
code: ERROR_INTERNAL_SERVER,
|
||||
data: e,
|
||||
cluster: details.name,
|
||||
};
|
||||
throw new ForwardedError(`Cluster ${details.name} request error`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,19 +266,20 @@ export class KubernetesProxy {
|
||||
cluster: cluster.name,
|
||||
};
|
||||
|
||||
const kc = new KubeConfig();
|
||||
const kubeConfig = new KubeConfig();
|
||||
|
||||
if (clusterDetails.serviceAccountToken) {
|
||||
kc.loadFromOptions({
|
||||
kubeConfig.loadFromOptions({
|
||||
clusters: [cluster],
|
||||
users: [user],
|
||||
contexts: [context],
|
||||
currentContext: context.name,
|
||||
});
|
||||
} else {
|
||||
kc.loadFromDefault();
|
||||
kubeConfig.loadFromDefault();
|
||||
}
|
||||
|
||||
return kc;
|
||||
return kubeConfig;
|
||||
}
|
||||
|
||||
private getBestResponseCode(codes: number[]): number {
|
||||
|
||||
@@ -280,11 +280,3 @@ export interface KubernetesObjectsProvider {
|
||||
customResourcesByEntity: CustomResourcesByEntity,
|
||||
): Promise<ObjectsByEntityResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface KubernetesProxyServices {
|
||||
kcs: KubernetesClustersSupplier;
|
||||
}
|
||||
|
||||
@@ -193,16 +193,12 @@ export interface KubernetesFetchError {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "KubernetesProxyClusters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface KubernetesProxyClusters {
|
||||
// (undocumented)
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "KubernetesRequestAuth" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface KubernetesRequestAuth {
|
||||
// (undocumented)
|
||||
|
||||
@@ -259,6 +259,7 @@ export interface ClientPodStatus {
|
||||
containers: ClientContainerStatus[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface KubernetesProxyClusters {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
@@ -5964,6 +5964,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@azure/identity": ^2.0.4
|
||||
"@backstage/backend-common": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/catalog-client": "workspace:^"
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
@@ -5988,6 +5989,8 @@ __metadata:
|
||||
lodash: ^4.17.21
|
||||
luxon: ^3.0.0
|
||||
morgan: ^1.10.0
|
||||
msw: ^0.48.0
|
||||
node-fetch: ^2.6.0
|
||||
stream-buffers: ^3.0.2
|
||||
supertest: ^6.1.3
|
||||
winston: ^3.2.1
|
||||
|
||||
Reference in New Issue
Block a user