feat: Liam Rathke pre RFC update commit squash

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Copy of proxy plugin work

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Adds B64 encoding, TLS verify check

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Sets up test scaffolding

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Finishes proxy implementation tests

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Removes deprecated buffer method

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Adds additional HTTP verbs

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Removes fallback

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Adds some content-agnosticness, removes nitpick

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Adds changeset

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Fixes TSC errors?

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Adds API Report docs

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Removes unnecessary KubernetesRequestAuth parameter

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Somehow adds new docs???

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Fixes some code inline with review comments

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Updates API report

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>

Fixes tests

Signed-off-by: Liam Rathke <liam.rathke@gmail.com>
This commit is contained in:
Liam Rathke
2022-08-11 15:48:18 -07:00
committed by Fredrik Adelöw
parent 5bb2d0f32b
commit b585179770
9 changed files with 708 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes-common': patch
---
Added Kubernetes proxy API route to backend Kubernetes plugin, allowing Backstage plugin developers to read/write new information from Kubernetes (if proper credentials are provided).
+13
View File
@@ -209,8 +209,15 @@ export class KubernetesBuilder {
// (undocumented)
protected getObjectTypesToFetch(): ObjectToFetch[] | undefined;
// (undocumented)
protected getProxyServices(): KubernetesProxyServices;
// (undocumented)
protected getServiceLocatorMethod(): ServiceLocatorMethod;
// (undocumented)
protected makeProxyRequest(
req: express.Request,
res: express.Response,
): Promise<void>;
// (undocumented)
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
// (undocumented)
setDefaultClusterRefreshInterval(refreshInterval: Duration): this;
@@ -322,6 +329,12 @@ export type KubernetesObjectTypes =
| 'statefulsets'
| 'daemonsets';
// @alpha (undocumented)
export interface KubernetesProxyServices {
// (undocumented)
kcs: KubernetesClustersSupplier;
}
// @alpha
export interface KubernetesServiceLocator {
// (undocumented)
+1
View File
@@ -56,6 +56,7 @@
"helmet": "^6.0.0",
"lodash": "^4.17.21",
"luxon": "^3.0.0",
"node-fetch": "^2.6.0",
"morgan": "^1.10.0",
"stream-buffers": "^3.0.2",
"winston": "^3.2.1",
@@ -30,8 +30,12 @@ import {
KubernetesFetcher,
KubernetesServiceLocator,
KubernetesObjectsProviderOptions,
KubernetesProxyServices,
} from '../types/types';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy';
import {
DEFAULT_OBJECTS,
KubernetesFanOutHandler,
@@ -76,12 +80,15 @@ export class KubernetesBuilder {
private objectsProvider?: KubernetesObjectsProvider;
private fetcher?: KubernetesFetcher;
private serviceLocator?: KubernetesServiceLocator;
private proxy: KubernetesProxy;
static createBuilder(env: KubernetesEnvironment) {
return new KubernetesBuilder(env);
}
constructor(protected readonly env: KubernetesEnvironment) {}
constructor(protected readonly env: KubernetesEnvironment) {
this.proxy = new KubernetesProxy(env.logger);
}
public async build(): KubernetesBuilderReturn {
const logger = this.env.logger;
@@ -273,6 +280,12 @@ 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));
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
return router;
@@ -325,4 +338,28 @@ export class KubernetesBuilder {
return objectTypesToFetch;
}
protected async makeProxyRequest(
req: express.Request,
res: express.Response,
) {
const services = this.getProxyServices();
const proxyResponse: KubernetesProxyResponse =
await this.proxy.handleProxyRequest(services, req);
res.status(proxyResponse.code).json(proxyResponse.data);
}
protected getProxyServices(): KubernetesProxyServices {
const kcs =
this.clusterSupplier ??
this.buildClusterSupplier(this.defaultClusterRefreshInterval);
if (!kcs) {
this.env.logger.error('could not find cluster supplier!');
}
return {
kcs,
};
}
}
@@ -0,0 +1,342 @@
/*
* Copyright 2022 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 { 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 fetch from 'node-fetch';
describe('KubernetesProxy', () => {
let _clientMock: any;
let sut: KubernetesProxy;
const buildEncodedRequest = (
clustersHeader: any,
query: string,
body?: any,
): Request => {
const encodedQuery = encodeURIComponent(query);
const encodedClusters = Buffer.from(
JSON.stringify(clustersHeader),
).toString('base64');
const req = {
params: {
encodedQuery,
},
header: (key: string) => {
let value: string = '';
switch (key) {
case 'Content-Type': {
value = 'application/json';
break;
}
case 'X-Kubernetes-Clusters': {
value = encodedClusters;
break;
}
default: {
break;
}
}
return value;
},
} as unknown as Request;
if (body) {
req.body = body;
}
return req;
};
const buildProxyServicesWithClusters = (
clusters: ClusterDetails[],
): KubernetesProxyServices => {
const kcs: KubernetesClustersSupplier = {
getClusters: async () => {
return clusters;
},
};
return {
kcs,
};
};
beforeEach(() => {
jest.resetAllMocks();
_clientMock = {
handleProxyRequest: jest.fn(),
};
sut = new KubernetesProxy(getVoidLogger());
});
it('should return a 404 if no clusters are found', async () => {
const services = buildProxyServicesWithClusters([]);
const req = buildEncodedRequest({}, 'api');
const result = await sut.handleProxyRequest(services, req);
expect(result.code).toEqual(404);
expect(fetch).not.toHaveBeenCalled();
});
it('should match the response code of the Kubernetes response (single cluster)', async () => {
const services = buildProxyServicesWithClusters([
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
]);
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
const apiResponse = {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
};
// @ts-ignore-next-line
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response(JSON.stringify(apiResponse), {
status: 299,
}),
);
const result = await sut.handleProxyRequest(services, req);
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([
{
name: 'cluster1',
url: 'http://localhost:9998',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
{
name: 'cluster2',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
]);
const req = buildEncodedRequest(
{ cluster1: 'token', cluster2: 'token' },
'api',
);
const apiResponse1 = {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
};
const apiResponse2 = {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
status: 'Failure',
message: 'Unauthorized',
reason: 'Unauthorized',
code: 401,
};
(fetch as jest.MockedFunction<typeof fetch>)
.mockResolvedValueOnce(
new Response(JSON.stringify(apiResponse1), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(apiResponse2), {
status: 401,
}),
);
const result = await sut.handleProxyRequest(services, req);
expect(fetch).toBeCalledTimes(2);
expect(result.code).toEqual(200);
});
it('should pass the exact response data from Kubernetes (single cluster)', async () => {
const services = buildProxyServicesWithClusters([
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
]);
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
const apiResponse = {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
};
// @ts-ignore-next-line
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(
new Response(JSON.stringify(apiResponse), {
status: 200,
}),
);
const result = await sut.handleProxyRequest(services, req);
const resultString = JSON.stringify(result.data);
const expectedString = JSON.stringify({
cluster1: {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
},
});
expect(fetch).toBeCalledTimes(1);
expect(resultString).toEqual(expectedString);
});
it('should pass the exact response data from Kubernetes (multi cluster)', async () => {
const services = buildProxyServicesWithClusters([
{
name: 'cluster1',
url: 'http://localhost:9998',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
{
name: 'cluster2',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
skipTLSVerify: true,
},
]);
const req = buildEncodedRequest(
{ cluster1: 'token', cluster2: 'token' },
'api',
);
const apiResponse1 = {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
};
const apiResponse2 = {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
status: 'Failure',
message: 'Unauthorized',
reason: 'Unauthorized',
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,
}),
);
const result = await sut.handleProxyRequest(services, req);
const resultString = JSON.stringify(result.data);
const expectedString = JSON.stringify({
cluster1: {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
},
cluster2: {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
status: 'Failure',
message: 'Unauthorized',
reason: 'Unauthorized',
code: 401,
},
});
expect(fetch).toBeCalledTimes(2);
expect(resultString).toEqual(expectedString);
});
});
@@ -0,0 +1,286 @@
/*
* Copyright 2022 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 { KubeConfig, bufferFromFileOrString } from '@kubernetes/client-node';
import { Logger } from 'winston';
import fetch from 'node-fetch';
import * as https from 'https';
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';
export interface KubernetesProxyResponse {
code: number;
data: any;
cluster?: string;
}
interface KubernetesProxyClusters {
[key: string]: string;
}
export class KubernetesProxy {
constructor(protected readonly logger: Logger) {}
public async handleProxyRequest(
services: KubernetesProxyServices,
req: Request,
): Promise<KubernetesProxyResponse> {
const krc = this.getKubernetesRequestedClusters(req);
if (Object.keys(krc).length < 1) {
return {
code: ERROR_NOT_FOUND,
data: 'No clusters found!',
};
}
const details = await this.getClusterDetails(services.kcs, krc);
if (details.length < 1) {
return {
code: ERROR_NOT_FOUND,
data: 'No clusters found!',
};
}
const responses = await Promise.all(
details.map(async d => {
const response = await this.makeRequestToCluster(d, req);
return response;
}),
);
const data: { [key: string]: any } = {};
const codes: number[] = [];
responses.forEach(kpr => {
if (kpr.cluster) {
data[kpr.cluster] = kpr.data;
codes.push(kpr.code);
}
});
const code = this.getBestResponseCode(codes);
const res: KubernetesProxyResponse = {
code,
data,
};
return res;
}
private getKubernetesRequestedClusters(
req: Request,
): KubernetesProxyClusters {
const encodedClusters: string =
req.header(HEADER_KUBERNETES_CLUSTERS) ?? '';
if (!encodedClusters) {
return {};
}
try {
const decodedClusters = Buffer.from(encodedClusters, 'base64').toString();
const clusters: KubernetesProxyClusters = JSON.parse(decodedClusters);
return clusters;
} catch (e: any) {
this.logger.debug(
`error with encoded cluster header: ${JSON.stringify(e)}`,
);
}
return {};
}
private async getClusterDetails(
clusterSupplier: KubernetesClustersSupplier,
krc: KubernetesProxyClusters,
): Promise<ClusterDetails[]> {
const clusters = await clusterSupplier.getClusters();
const clusterNames = Object.keys(krc);
const clusterDetails = clusters.filter(c => clusterNames.includes(c.name));
const clusterDetailsAuth = clusterDetails.map(c => {
const cAuth: ClusterDetails = Object.assign(c, {
serviceAccountToken: krc[c.name],
});
return cAuth;
});
return clusterDetailsAuth;
}
private getClusterURI(details: ClusterDetails): string {
const client = this.getKubeConfig(details);
return client.getCurrentCluster()?.server || '';
}
private async makeRequestToCluster(
details: ClusterDetails,
req: Request,
): Promise<KubernetesProxyResponse> {
const serverIP = this.getClusterURI(details);
if (!serverIP) {
return {
code: ERROR_INTERNAL_SERVER,
data: null,
};
}
const query = decodeURIComponent(req.params.encodedQuery) || '';
const uri = `${serverIP}/${query}`;
const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON;
const res = await this.sendClusterRequest(
details,
uri,
req.method,
contentType,
req.body,
);
return res;
}
private async sendClusterRequest(
details: ClusterDetails,
uri: string,
method: string,
contentType: string,
body?: any,
): Promise<KubernetesProxyResponse> {
const bearerToken = details.serviceAccountToken;
if (!bearerToken) {
return {
code: ERROR_BAD_REQUEST,
data: {
error: 'Invalid service account token',
},
};
}
const reqData: any = {
method,
headers: {
'Content-Type': contentType,
Authorization: `Bearer ${bearerToken}`,
},
};
if (!details.skipTLSVerify) {
if (details.caData) {
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',
},
};
}
}
if (body && Object.keys(body).length > 0) {
reqData.body = JSON.stringify(body);
}
try {
const req = await fetch(uri, reqData);
let res;
if (contentType.includes(APPLICATION_JSON)) {
res = await req.json();
} else {
res = await req.text();
}
const proxyResponse: KubernetesProxyResponse = {
code: req.status,
data: res,
cluster: details.name,
};
return proxyResponse;
} catch (e: any) {
return {
code: ERROR_INTERNAL_SERVER,
data: e,
cluster: details.name,
};
}
}
private getKubeConfig(clusterDetails: ClusterDetails): KubeConfig {
const cluster = {
name: clusterDetails.name,
server: clusterDetails.url,
skipTLSVerify: clusterDetails.skipTLSVerify,
caData: clusterDetails.caData,
};
const user = {
name: CLUSTER_USER_NAME,
token: clusterDetails.serviceAccountToken,
};
const context = {
name: clusterDetails.name,
user: user.name,
cluster: cluster.name,
};
const kc = new KubeConfig();
if (clusterDetails.serviceAccountToken) {
kc.loadFromOptions({
clusters: [cluster],
users: [user],
contexts: [context],
currentContext: context.name,
});
} else {
kc.loadFromDefault();
}
return kc;
}
private getBestResponseCode(codes: number[]): number {
const sorted = codes.sort();
return sorted[0] ?? ERROR_INTERNAL_SERVER;
}
}
@@ -280,3 +280,11 @@ export interface KubernetesObjectsProvider {
customResourcesByEntity: CustomResourcesByEntity,
): Promise<ObjectsByEntityResponse>;
}
/**
*
* @alpha
*/
export interface KubernetesProxyServices {
kcs: KubernetesClustersSupplier;
}
+10
View File
@@ -193,6 +193,16 @@ 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)
+4
View File
@@ -258,3 +258,7 @@ export interface ClientPodStatus {
memory: ClientCurrentResourceUsage;
containers: ClientContainerStatus[];
}
export interface KubernetesProxyClusters {
[key: string]: string;
}