Merge pull request #13026 from liamrathke/poc-k8s-proxy-2
Kubernetes Backstage Plugin - Kubernetes Proxy API Route
This commit is contained in:
@@ -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).
|
||||
@@ -22,6 +22,7 @@ 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 type { RequestHandler } from 'express';
|
||||
import { TokenCredential } from '@azure/identity';
|
||||
|
||||
// @alpha (undocumented)
|
||||
@@ -142,6 +143,9 @@ export class GoogleServiceAccountAuthTranslator
|
||||
): Promise<GKEClusterDetails>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const HEADER_KUBERNETES_CLUSTER: string;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface KubernetesAuthTranslator {
|
||||
// (undocumented)
|
||||
@@ -188,10 +192,16 @@ export class KubernetesBuilder {
|
||||
options: KubernetesObjectsProviderOptions,
|
||||
): KubernetesObjectsProvider;
|
||||
// (undocumented)
|
||||
protected buildProxy(
|
||||
logger: Logger,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesProxy;
|
||||
// (undocumented)
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
proxy: KubernetesProxy,
|
||||
): express.Router;
|
||||
// (undocumented)
|
||||
protected buildServiceLocator(
|
||||
@@ -207,8 +217,23 @@ 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 getProxy(
|
||||
logger: Logger,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesProxy;
|
||||
// (undocumented)
|
||||
protected getServiceLocator(): KubernetesServiceLocator;
|
||||
// (undocumented)
|
||||
protected getServiceLocatorMethod(): ServiceLocatorMethod;
|
||||
// (undocumented)
|
||||
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
|
||||
@@ -219,6 +244,8 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this;
|
||||
// (undocumented)
|
||||
setProxy(proxy?: KubernetesProxy): this;
|
||||
// (undocumented)
|
||||
setServiceLocator(serviceLocator?: KubernetesServiceLocator): this;
|
||||
}
|
||||
|
||||
@@ -228,6 +255,7 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
customResources: CustomResource[];
|
||||
fetcher: KubernetesFetcher;
|
||||
proxy: KubernetesProxy;
|
||||
objectsProvider: KubernetesObjectsProvider;
|
||||
serviceLocator: KubernetesServiceLocator;
|
||||
}>;
|
||||
@@ -322,6 +350,13 @@ export type KubernetesObjectTypes =
|
||||
| 'statefulsets'
|
||||
| 'daemonsets';
|
||||
|
||||
// @alpha
|
||||
export class KubernetesProxy {
|
||||
constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier);
|
||||
// (undocumented)
|
||||
createRequestHandler(): RequestHandler;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface KubernetesServiceLocator {
|
||||
// (undocumented)
|
||||
|
||||
@@ -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:^",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^",
|
||||
"@google-cloud/container": "^4.0.0",
|
||||
"@jest-mock/express": "^2.0.1",
|
||||
"@kubernetes/client-node": "0.17.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/luxon": "^3.0.0",
|
||||
@@ -54,9 +56,11 @@
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "10.1.0",
|
||||
"helmet": "^6.0.0",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"stream-buffers": "^3.0.2",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
@@ -64,7 +68,9 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/aws4": "^1.5.1",
|
||||
"@types/http-proxy-middleware": "^0.19.3",
|
||||
"aws-sdk-mock": "^5.2.1",
|
||||
"msw": "^0.49.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -20,19 +20,6 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './kubernetes-auth-translator/AwsIamKubernetesAuthTranslator';
|
||||
export * from './kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator';
|
||||
export * from './kubernetes-auth-translator/GoogleKubernetesAuthTranslator';
|
||||
export * from './kubernetes-auth-translator/GoogleServiceAccountAuthProvider';
|
||||
export * from './kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
|
||||
export * from './kubernetes-auth-translator/NoopKubernetesAuthTranslator';
|
||||
export * from './kubernetes-auth-translator/OidcKubernetesAuthTranslator';
|
||||
export * from './kubernetes-auth-translator/types';
|
||||
|
||||
export * from './service/router';
|
||||
export * from './service/KubernetesBuilder';
|
||||
export * from './service/KubernetesClientProvider';
|
||||
|
||||
export * from './types/types';
|
||||
|
||||
export { DEFAULT_OBJECTS } from './service/KubernetesFanOutHandler';
|
||||
export * from './kubernetes-auth-translator';
|
||||
export * from './service';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './AwsIamKubernetesAuthTranslator';
|
||||
export * from './AzureIdentityKubernetesAuthTranslator';
|
||||
export * from './GoogleKubernetesAuthTranslator';
|
||||
export * from './GoogleServiceAccountAuthProvider';
|
||||
export * from './KubernetesAuthTranslatorGenerator';
|
||||
export * from './NoopKubernetesAuthTranslator';
|
||||
export * from './OidcKubernetesAuthTranslator';
|
||||
export * from './types';
|
||||
@@ -13,23 +13,26 @@
|
||||
* 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,
|
||||
KubernetesObjectTypes,
|
||||
KubernetesServiceLocator,
|
||||
ObjectsByEntityRequest,
|
||||
ServiceLocatorMethod,
|
||||
} from '../types/types';
|
||||
import { KubernetesClientProvider } from './KubernetesClientProvider';
|
||||
import {
|
||||
@@ -37,8 +40,7 @@ import {
|
||||
KubernetesFanOutHandler,
|
||||
} from './KubernetesFanOutHandler';
|
||||
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
|
||||
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { KubernetesProxy } from './KubernetesProxy';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -60,6 +62,7 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
customResources: CustomResource[];
|
||||
fetcher: KubernetesFetcher;
|
||||
proxy: KubernetesProxy;
|
||||
objectsProvider: KubernetesObjectsProvider;
|
||||
serviceLocator: KubernetesServiceLocator;
|
||||
}>;
|
||||
@@ -76,6 +79,7 @@ export class KubernetesBuilder {
|
||||
private objectsProvider?: KubernetesObjectsProvider;
|
||||
private fetcher?: KubernetesFetcher;
|
||||
private serviceLocator?: KubernetesServiceLocator;
|
||||
private proxy?: KubernetesProxy;
|
||||
|
||||
static createBuilder(env: KubernetesEnvironment) {
|
||||
return new KubernetesBuilder(env);
|
||||
@@ -102,36 +106,34 @@ 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 proxy = this.getProxy(logger, clusterSupplier);
|
||||
|
||||
const objectsProvider =
|
||||
this.objectsProvider ??
|
||||
this.buildObjectsProvider({
|
||||
logger,
|
||||
fetcher,
|
||||
serviceLocator,
|
||||
customResources,
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
const serviceLocator = this.getServiceLocator();
|
||||
|
||||
const objectsProvider = this.getObjectsProvider({
|
||||
logger,
|
||||
fetcher,
|
||||
serviceLocator,
|
||||
customResources,
|
||||
objectTypesToFetch: this.getObjectTypesToFetch(),
|
||||
});
|
||||
|
||||
const router = this.buildRouter(
|
||||
objectsProvider,
|
||||
clusterSupplier,
|
||||
this.env.catalogApi,
|
||||
proxy,
|
||||
);
|
||||
|
||||
return {
|
||||
clusterSupplier,
|
||||
customResources,
|
||||
fetcher,
|
||||
proxy,
|
||||
objectsProvider,
|
||||
router,
|
||||
serviceLocator,
|
||||
@@ -163,6 +165,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') ?? []
|
||||
@@ -186,24 +193,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(
|
||||
@@ -212,14 +224,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(
|
||||
@@ -234,10 +251,19 @@ export class KubernetesBuilder {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
protected buildProxy(
|
||||
logger: Logger,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesProxy {
|
||||
this.proxy = new KubernetesProxy(logger, clusterSupplier);
|
||||
return this.proxy;
|
||||
}
|
||||
|
||||
protected buildRouter(
|
||||
objectsProvider: KubernetesObjectsProvider,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
catalogApi: CatalogApi,
|
||||
proxy: KubernetesProxy,
|
||||
): express.Router {
|
||||
const logger = this.env.logger;
|
||||
const router = Router();
|
||||
@@ -273,6 +299,8 @@ export class KubernetesBuilder {
|
||||
});
|
||||
});
|
||||
|
||||
router.use('/proxy', proxy.createRequestHandler());
|
||||
|
||||
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
|
||||
|
||||
return router;
|
||||
@@ -296,6 +324,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',
|
||||
@@ -325,4 +378,11 @@ export class KubernetesBuilder {
|
||||
|
||||
return objectTypesToFetch;
|
||||
}
|
||||
|
||||
protected getProxy(
|
||||
logger: Logger,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
) {
|
||||
return this.proxy ?? this.buildProxy(logger, clusterSupplier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 'buffer';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { getMockReq, getMockRes } from '@jest-mock/express';
|
||||
import type { Request } from 'express';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
import {
|
||||
APPLICATION_JSON,
|
||||
HEADER_KUBERNETES_CLUSTER,
|
||||
KubernetesProxy,
|
||||
} from './KubernetesProxy';
|
||||
|
||||
describe('KubernetesProxy', () => {
|
||||
let proxy: KubernetesProxy;
|
||||
const worker = setupServer();
|
||||
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
const buildMockRequest = (clusterName: any, path: string): Request => {
|
||||
const req = getMockReq({
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
header: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'Content-Type': {
|
||||
return APPLICATION_JSON;
|
||||
}
|
||||
case HEADER_KUBERNETES_CLUSTER: {
|
||||
return clusterName;
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
return req;
|
||||
};
|
||||
|
||||
const clusterSupplier: jest.Mocked<KubernetesClustersSupplier> = {
|
||||
getClusters: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier);
|
||||
});
|
||||
|
||||
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
|
||||
clusterSupplier.getClusters.mockResolvedValue([]);
|
||||
|
||||
const req = buildMockRequest('test', 'api');
|
||||
const { res, next } = getMockRes();
|
||||
|
||||
await expect(proxy.createRequestHandler()(req, res, next)).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass the exact response from Kubernetes', async () => {
|
||||
const apiResponse = {
|
||||
kind: 'APIVersions',
|
||||
versions: ['v1'],
|
||||
serverAddressByClientCIDRs: [
|
||||
{
|
||||
clientCIDR: '0.0.0.0/0',
|
||||
serverAddress: '192.168.0.1:3333',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: '',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
const app = express().use('/mountpath', proxy.createRequestHandler());
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api', (_, res, ctx) =>
|
||||
res(ctx.status(299), ctx.json(apiResponse)),
|
||||
),
|
||||
rest.all(requestPromise.url, (req, _res, _ctx) => req.passthrough()),
|
||||
);
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
expect(response.status).toEqual(299);
|
||||
expect(response.body).toStrictEqual(apiResponse);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 {
|
||||
ErrorResponseBody,
|
||||
ForwardedError,
|
||||
InputError,
|
||||
NotFoundError,
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { bufferFromFileOrString } from '@kubernetes/client-node';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { Logger } from 'winston';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
|
||||
export const APPLICATION_JSON: string = 'application/json';
|
||||
|
||||
/**
|
||||
* The header that is used to specify the cluster name.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster';
|
||||
|
||||
/**
|
||||
* A proxy that routes requests to the Kubernetes API.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export class KubernetesProxy {
|
||||
private readonly middlewareForClusterName = new Map<string, RequestHandler>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly clusterSupplier: KubernetesClustersSupplier,
|
||||
) {}
|
||||
|
||||
public createRequestHandler(): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
const middleware = await this.getMiddleware(req);
|
||||
middleware(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
// We create one middleware per remote cluster and hold on to them, because
|
||||
// the secure property isn't possible to decide on a per-request basis with a
|
||||
// single middleware instance - and we don't expect it to change over time.
|
||||
private async getMiddleware(originalReq: Request): Promise<RequestHandler> {
|
||||
const originalCluster = await this.getClusterForRequest(originalReq);
|
||||
let middleware = this.middlewareForClusterName.get(originalCluster.name);
|
||||
if (!middleware) {
|
||||
// Probably too risky without permissions protecting this endpoint
|
||||
// if (cluster.serviceAccountToken) {
|
||||
// options.headers = {
|
||||
// Authorization: `Bearer ${cluster.serviceAccountToken}`,
|
||||
// };
|
||||
// }
|
||||
|
||||
const logger = this.logger.child({ cluster: originalCluster.name });
|
||||
middleware = createProxyMiddleware({
|
||||
logProvider: () => logger,
|
||||
secure: !originalCluster.skipTLSVerify,
|
||||
router: async req => {
|
||||
// Re-evaluate the cluster on each request, in case it has changed
|
||||
const cluster = await this.getClusterForRequest(req);
|
||||
const url = new URL(cluster.url);
|
||||
return {
|
||||
protocol: url.protocol,
|
||||
host: url.hostname,
|
||||
port: url.port,
|
||||
ca: bufferFromFileOrString('', cluster.caData)?.toString(),
|
||||
};
|
||||
},
|
||||
pathRewrite: { [`^${originalReq.baseUrl}`]: '' },
|
||||
onError: (error, req, res) => {
|
||||
const wrappedError = new ForwardedError(
|
||||
`Cluster '${originalCluster.name}' request error`,
|
||||
error,
|
||||
);
|
||||
|
||||
logger.error(wrappedError);
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: serializeError(wrappedError, {
|
||||
includeStack: process.env.NODE_ENV === 'development',
|
||||
}),
|
||||
request: { method: req.method, url: req.originalUrl },
|
||||
response: { statusCode: 500 },
|
||||
};
|
||||
|
||||
res.status(500).json(body);
|
||||
},
|
||||
});
|
||||
|
||||
this.middlewareForClusterName.set(originalCluster.name, middleware);
|
||||
}
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
private async getClusterForRequest(req: Request): Promise<ClusterDetails> {
|
||||
const clusterName = req.header(HEADER_KUBERNETES_CLUSTER);
|
||||
if (!clusterName) {
|
||||
throw new InputError(`Missing '${HEADER_KUBERNETES_CLUSTER}' header.`);
|
||||
}
|
||||
|
||||
const cluster = await this.clusterSupplier
|
||||
.getClusters()
|
||||
.then(clusters => clusters.find(c => c.name === clusterName));
|
||||
if (!cluster) {
|
||||
throw new NotFoundError(`Cluster '${clusterName}' not found`);
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './KubernetesBuilder';
|
||||
export * from './KubernetesClientProvider';
|
||||
export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler';
|
||||
export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy';
|
||||
export * from './router';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
@@ -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:^"
|
||||
@@ -5972,9 +5973,11 @@ __metadata:
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^"
|
||||
"@google-cloud/container": ^4.0.0
|
||||
"@jest-mock/express": ^2.0.1
|
||||
"@kubernetes/client-node": 0.17.0
|
||||
"@types/aws4": ^1.5.1
|
||||
"@types/express": ^4.17.6
|
||||
"@types/http-proxy-middleware": ^0.19.3
|
||||
"@types/luxon": ^3.0.0
|
||||
aws-sdk: ^2.840.0
|
||||
aws-sdk-mock: ^5.2.1
|
||||
@@ -5985,9 +5988,12 @@ __metadata:
|
||||
express-promise-router: ^4.1.0
|
||||
fs-extra: 10.1.0
|
||||
helmet: ^6.0.0
|
||||
http-proxy-middleware: ^2.0.6
|
||||
lodash: ^4.17.21
|
||||
luxon: ^3.0.0
|
||||
morgan: ^1.10.0
|
||||
msw: ^0.49.0
|
||||
node-fetch: ^2.6.7
|
||||
stream-buffers: ^3.0.2
|
||||
supertest: ^6.1.3
|
||||
winston: ^3.2.1
|
||||
@@ -9265,6 +9271,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jest-mock/express@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@jest-mock/express@npm:2.0.1"
|
||||
dependencies:
|
||||
"@types/express": ^4.17.13
|
||||
checksum: 999ea0a953b3e911d0b8ecc4cb3b78ac252b3354832db9659be6754094410b10508f1688f3e623ab13fe40c28d89fd5a81699e6acd148dd8128abec4e81f3f14
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jest/console@npm:^29.0.3":
|
||||
version: 29.0.3
|
||||
resolution: "@jest/console@npm:29.0.3"
|
||||
@@ -23359,7 +23374,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3":
|
||||
"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "http-proxy-middleware@npm:2.0.6"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user