change decorateClusterDetailsWithAuth method to use the authTranslator provided by the KubernetesFanOutHandler constructor

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-02-21 15:41:28 -05:00
parent 074f625388
commit e6c7c85012
10 changed files with 366 additions and 68 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': minor
---
Plugins that instantiate the `KubernetesProxy` must now provide a parameter of the type `KubernetesProxyOptions` which includes providing a `KubernetesAuthTranslator`. The `KubernetesBuilder` now builds its own `KubernetesAuthTranslatorMap` that it provides to the `KubernetesProxy`. The `DispatchingKubernetesAuthTranslator` expects a `KubernetesTranslatorMap` to be provided as a parameter. The `KubernetesBuilder` now has a method called `setAuthTranslatorMap` which allows integrators to bring their own `KubernetesAuthTranslator's` to the `KubernetesPlugin`.
+42 -12
View File
@@ -110,6 +110,25 @@ export interface CustomResourcesByEntity extends KubernetesObjectsByEntity {
// @public (undocumented)
export const DEFAULT_OBJECTS: ObjectToFetch[];
// @public
export class DispatchingKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
constructor(options: DispatchingKubernetesAuthTranslatorOptions);
// (undocumented)
decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
): Promise<ClusterDetails>;
}
// @public (undocumented)
export type DispatchingKubernetesAuthTranslatorOptions = {
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
};
};
// @public (undocumented)
export interface FetchResponseWrapper {
// (undocumented)
@@ -157,23 +176,16 @@ export interface KubernetesAuthTranslator {
): Promise<ClusterDetails>;
}
// @public (undocumented)
export class KubernetesAuthTranslatorGenerator {
// (undocumented)
static getKubernetesAuthTranslatorInstance(
authProvider: string,
options: {
logger: Logger;
},
): KubernetesAuthTranslator;
}
// @public (undocumented)
export class KubernetesBuilder {
constructor(env: KubernetesEnvironment);
// (undocumented)
build(): KubernetesBuilderReturn;
// (undocumented)
protected buildAuthTranslatorMap(): {
[key: string]: KubernetesAuthTranslator;
};
// (undocumented)
protected buildClusterSupplier(
refreshInterval: Duration,
): KubernetesClustersSupplier;
@@ -220,6 +232,10 @@ export class KubernetesBuilder {
clusterSupplier: KubernetesClustersSupplier,
): Promise<ClusterDetails[]>;
// (undocumented)
protected getAuthTranslatorMap(): {
[key: string]: KubernetesAuthTranslator;
};
// (undocumented)
protected getClusterSupplier(): KubernetesClustersSupplier;
// (undocumented)
protected getFetcher(): KubernetesFetcher;
@@ -239,6 +255,10 @@ export class KubernetesBuilder {
// (undocumented)
protected getServiceLocatorMethod(): ServiceLocatorMethod;
// (undocumented)
setAuthTranslatorMap(authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
}): void;
// (undocumented)
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
// (undocumented)
setDefaultClusterRefreshInterval(refreshInterval: Duration): this;
@@ -261,6 +281,9 @@ export type KubernetesBuilderReturn = Promise<{
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
};
}>;
// @public
@@ -345,7 +368,7 @@ export type KubernetesObjectTypes =
// @public
export class KubernetesProxy {
constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier);
constructor(options: KubernetesProxyOptions);
// (undocumented)
createRequestHandler(
options: KubernetesProxyCreateRequestHandlerOptions,
@@ -357,6 +380,13 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
permissionApi: PermissionEvaluator;
};
// @public
export type KubernetesProxyOptions = {
logger: Logger;
clusterSupplier: KubernetesClustersSupplier;
authTranslator: KubernetesAuthTranslator;
};
// @public
export interface KubernetesServiceLocator {
// (undocumented)
@@ -31,7 +31,7 @@ describe('decorateClusterDetailsWithAuth', () => {
});
});
it('can decorate cluster details if the auth provider is in the translator map', () => {
it('can decorate cluster details if the auth provider is in the translator map', async () => {
const expectedClusterDetails: ClusterDetails = {
url: 'notanything.com',
name: 'randomName',
@@ -39,11 +39,11 @@ describe('decorateClusterDetailsWithAuth', () => {
serviceAccountToken: 'added by mock translator',
};
mockTranslator.decorateClusterDetailsWithAuth.mockReturnValue(
expectedClusterDetails as unknown as Promise<ClusterDetails>,
mockTranslator.decorateClusterDetailsWithAuth.mockResolvedValue(
expectedClusterDetails,
);
const returnedValue = authTranslator.decorateClusterDetailsWithAuth(
const returnedValue = await authTranslator.decorateClusterDetailsWithAuth(
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
authObject,
);
@@ -22,7 +22,7 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
*
* @public
*/
export type KubernetesAuthTranslatorGeneratorOptions = {
export type DispatchingKubernetesAuthTranslatorOptions = {
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
};
@@ -36,7 +36,7 @@ export class DispatchingKubernetesAuthTranslator
{
private readonly translatorMap: { [key: string]: KubernetesAuthTranslator };
constructor(options: KubernetesAuthTranslatorGeneratorOptions) {
constructor(options: DispatchingKubernetesAuthTranslatorOptions) {
this.translatorMap = options.authTranslatorMap;
}
@@ -24,6 +24,17 @@ import { Duration } from 'luxon';
import { Logger } from 'winston';
import { getCombinedClusterSupplier } from '../cluster-locator';
import {
KubernetesAuthTranslator,
DispatchingKubernetesAuthTranslator,
GoogleKubernetesAuthTranslator,
NoopKubernetesAuthTranslator,
AwsIamKubernetesAuthTranslator,
GoogleServiceAccountAuthTranslator,
AzureIdentityKubernetesAuthTranslator,
OidcKubernetesAuthTranslator,
} from '../kubernetes-auth-translator';
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
import {
@@ -68,6 +79,7 @@ export type KubernetesBuilderReturn = Promise<{
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
authTranslatorMap: { [key: string]: KubernetesAuthTranslator };
}>;
/**
@@ -83,6 +95,7 @@ export class KubernetesBuilder {
private fetcher?: KubernetesFetcher;
private serviceLocator?: KubernetesServiceLocator;
private proxy?: KubernetesProxy;
private authTranslatorMap?: { [key: string]: KubernetesAuthTranslator };
static createBuilder(env: KubernetesEnvironment) {
return new KubernetesBuilder(env);
@@ -114,6 +127,8 @@ export class KubernetesBuilder {
const clusterSupplier = this.getClusterSupplier();
const authTranslatorMap = this.getAuthTranslatorMap();
const proxy = this.getProxy(logger, clusterSupplier);
const serviceLocator = this.getServiceLocator();
@@ -142,6 +157,7 @@ export class KubernetesBuilder {
objectsProvider,
router,
serviceLocator,
authTranslatorMap,
};
}
@@ -175,6 +191,12 @@ export class KubernetesBuilder {
return this;
}
public setAuthTranslatorMap(authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
}) {
this.authTranslatorMap = authTranslatorMap;
}
protected buildCustomResources() {
const customResources: CustomResource[] = (
this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? []
@@ -210,7 +232,14 @@ export class KubernetesBuilder {
protected buildObjectsProvider(
options: KubernetesObjectsProviderOptions,
): KubernetesObjectsProvider {
this.objectsProvider = new KubernetesFanOutHandler(options);
const authTranslatorMap = this.getAuthTranslatorMap();
this.objectsProvider = new KubernetesFanOutHandler({
...options,
authTranslator: new DispatchingKubernetesAuthTranslator({
authTranslatorMap,
}),
});
return this.objectsProvider;
}
@@ -259,7 +288,15 @@ export class KubernetesBuilder {
logger: Logger,
clusterSupplier: KubernetesClustersSupplier,
): KubernetesProxy {
this.proxy = new KubernetesProxy(logger, clusterSupplier);
const authTranslatorMap = this.getAuthTranslatorMap();
const authTranslator = new DispatchingKubernetesAuthTranslator({
authTranslatorMap,
});
this.proxy = new KubernetesProxy({
logger,
clusterSupplier,
authTranslator,
});
return this.proxy;
}
@@ -314,6 +351,19 @@ export class KubernetesBuilder {
return router;
}
protected buildAuthTranslatorMap() {
this.authTranslatorMap = {
google: new GoogleKubernetesAuthTranslator(),
aws: new AwsIamKubernetesAuthTranslator(),
azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger),
serviceAccount: new NoopKubernetesAuthTranslator(),
googleServiceAccount: new GoogleServiceAccountAuthTranslator(),
oidc: new OidcKubernetesAuthTranslator(),
localKubectlProxy: new NoopKubernetesAuthTranslator(),
};
return this.authTranslatorMap;
}
protected async fetchClusterDetails(
clusterSupplier: KubernetesClustersSupplier,
) {
@@ -393,4 +443,8 @@ export class KubernetesBuilder {
) {
return this.proxy ?? this.buildProxy(logger, clusterSupplier);
}
protected getAuthTranslatorMap() {
return this.authTranslatorMap ?? this.buildAuthTranslatorMap();
}
}
@@ -166,6 +166,11 @@ function getKubernetesFanOutHandler(customResources: CustomResource[]) {
getClustersByEntity,
},
customResources: customResources,
authTranslator: {
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
return clusterDetails;
},
},
});
}
@@ -879,6 +884,11 @@ describe('getKubernetesObjectsByEntity', () => {
objectType: 'services',
},
],
authTranslator: {
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
return clusterDetails;
},
},
});
const result = await sut.getKubernetesObjectsByEntity({
@@ -30,7 +30,6 @@ import {
ServiceLocatorRequestContext,
} from '../types/types';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
import {
ClientContainerStatus,
ClientCurrentResourceUsage,
@@ -137,7 +136,9 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [
];
export interface KubernetesFanOutHandlerOptions
extends KubernetesObjectsProviderOptions {}
extends KubernetesObjectsProviderOptions {
authTranslator: KubernetesAuthTranslator;
}
export interface KubernetesRequestBody extends ObjectsByEntityRequest {}
@@ -195,7 +196,7 @@ export class KubernetesFanOutHandler {
private readonly serviceLocator: KubernetesServiceLocator;
private readonly customResources: CustomResource[];
private readonly objectTypesToFetch: Set<ObjectToFetch>;
private readonly authTranslators: Record<string, KubernetesAuthTranslator>;
private readonly authTranslator: KubernetesAuthTranslator;
constructor({
logger,
@@ -203,13 +204,14 @@ export class KubernetesFanOutHandler {
serviceLocator,
customResources,
objectTypesToFetch = DEFAULT_OBJECTS,
authTranslator,
}: KubernetesFanOutHandlerOptions) {
this.logger = logger;
this.fetcher = fetcher;
this.serviceLocator = serviceLocator;
this.customResources = customResources;
this.objectTypesToFetch = new Set(objectTypesToFetch);
this.authTranslators = {};
this.authTranslator = authTranslator;
}
async getCustomResourcesByEntity({
@@ -313,12 +315,7 @@ export class KubernetesFanOutHandler {
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
const promiseResults = await Promise.allSettled(
clusterDetails.map(cd => {
const kubernetesAuthTranslator: KubernetesAuthTranslator =
this.getAuthTranslator(cd.authProvider);
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
cd,
auth,
);
return this.authTranslator.decorateClusterDetailsWithAuth(cd, auth);
}),
);
@@ -393,19 +390,4 @@ export class KubernetesFanOutHandler {
result.errors.push(...podMetrics.errors);
return [result, podMetrics.responses as PodStatusFetchResponse[]];
}
private getAuthTranslator(provider: string): KubernetesAuthTranslator {
if (this.authTranslators[provider]) {
return this.authTranslators[provider];
}
this.authTranslators[provider] =
KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
provider,
{
logger: this.logger,
},
);
return this.authTranslators[provider];
}
}
@@ -28,16 +28,19 @@ import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import {
APPLICATION_JSON,
HEADER_KUBERNETES_CLUSTER,
HEADER_KUBERNETES_AUTH,
KubernetesProxy,
} from './KubernetesProxy';
import {
AuthorizeResult,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator';
describe('KubernetesProxy', () => {
let proxy: KubernetesProxy;
const worker = setupServer();
const logger = getVoidLogger();
setupRequestMockHandlers(worker);
@@ -73,9 +76,13 @@ describe('KubernetesProxy', () => {
authorizeConditional: jest.fn(),
};
const authTranslator: jest.Mocked<KubernetesAuthTranslator> = {
decorateClusterDetailsWithAuth: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier);
proxy = new KubernetesProxy({ logger, clusterSupplier, authTranslator });
});
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
@@ -112,21 +119,30 @@ describe('KubernetesProxy', () => {
authProvider: 'serviceAccount',
},
] as ClusterDetails[]);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
const app = express().use(
'/mountpath',
proxy.createRequestHandler({ permissionApi }),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const requestPromise = request(app)
.get('/mountpath/api')
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
worker.use(
rest.get('https://localhost:9999/api', (_, res, ctx) =>
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
res(ctx.status(299), ctx.json(apiResponse)),
),
rest.all(requestPromise.url, (req, _res, _ctx) => req.passthrough()),
rest.all(requestPromise.url, (req: any) => req.passthrough()),
);
const response = await requestPromise;
@@ -134,4 +150,188 @@ describe('KubernetesProxy', () => {
expect(response.status).toEqual(299);
expect(response.body).toStrictEqual(apiResponse);
});
it('should default to using a provided authorization header', async () => {
worker.use(
rest.get(
'https://localhost:9999/api/v1/namespaces',
(req: any, res: any, ctx: any) => {
if (!req.headers.get('Authorization')) {
return res(ctx.status(401));
}
if (req.headers.get('Authorization') !== 'my-token') {
return res(ctx.status(403));
}
return res(
ctx.status(200),
ctx.json({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
}),
);
},
),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'random-token',
authProvider: 'serviceAccount',
} as ClusterDetails);
const app = express().use(
'/mountpath',
proxy.createRequestHandler({ permissionApi }),
);
const requestPromise = request(app)
.get('/mountpath/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1')
.set('Authorization', 'my-token');
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
const response = await requestPromise;
expect(response.status).toEqual(200);
});
it('should add a serviceAccountToken to the request headers if one isnt provided in request and one isnt set up in cluster details', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
if (!req.headers.get('Authorization')) {
return res(ctx.status(401));
}
if (req.headers.get('Authorization') !== 'Bearer my-token') {
return res(ctx.status(403));
}
return res(
ctx.status(200),
ctx.json({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
}),
);
}),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'my-token',
authProvider: 'googleServiceAccount',
} as ClusterDetails);
const app = express().use(
'/mountpath',
proxy.createRequestHandler({ permissionApi }),
);
const requestPromise = request(app)
.get('/mountpath/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
const response = await requestPromise;
expect(response.status).toEqual(200);
expect(response.body).toStrictEqual({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
});
});
it('should append the Backstage-Kubernetes-Auth field to the requests authorization header if one is provided', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
if (!req.headers.get('Authorization')) {
return res(ctx.status(401));
}
if (req.headers.get('Authorization') !== 'tokenB') {
return res(ctx.status(403));
}
return res(
ctx.status(200),
ctx.json({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
}),
);
}),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails);
const app = express().use(
'/mountpath',
proxy.createRequestHandler({ permissionApi }),
);
const requestPromise = request(app)
.get('/mountpath/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1')
.set(HEADER_KUBERNETES_AUTH, 'tokenB');
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
const response = await requestPromise;
expect(response.status).toEqual(200);
expect(response.body).toStrictEqual({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
});
});
});
@@ -32,6 +32,7 @@ import { bufferFromFileOrString } from '@kubernetes/client-node';
import type { Request, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { Logger } from 'winston';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
export const APPLICATION_JSON: string = 'application/json';
@@ -60,6 +61,17 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
permissionApi: PermissionEvaluator;
};
/**
* Options accepted as a parameter by the KubernetesProxy
*
* @public
*/
export type KubernetesProxyOptions = {
logger: Logger;
clusterSupplier: KubernetesClustersSupplier;
authTranslator: KubernetesAuthTranslator;
};
/**
* A proxy that routes requests to the Kubernetes API.
*
@@ -67,11 +79,15 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
*/
export class KubernetesProxy {
private readonly middlewareForClusterName = new Map<string, RequestHandler>();
private readonly logger: Logger;
private readonly clusterSupplier: KubernetesClustersSupplier;
private readonly authTranslator: KubernetesAuthTranslator;
constructor(
private readonly logger: Logger,
private readonly clusterSupplier: KubernetesClustersSupplier,
) {}
constructor(options: KubernetesProxyOptions) {
this.logger = options.logger;
this.clusterSupplier = options.clusterSupplier;
this.authTranslator = options.authTranslator;
}
public createRequestHandler(
options: KubernetesProxyCreateRequestHandlerOptions,
@@ -96,6 +112,12 @@ export class KubernetesProxy {
return;
}
const cluster = await this.getClusterForRequest(req).then(cd =>
this.authTranslator.decorateClusterDetailsWithAuth(cd, {}),
);
if (!req.headers.authorization) {
req.headers.authorization = `Bearer ${cluster.serviceAccountToken}`;
}
const middleware = await this.getMiddleware(req);
middleware(req, res, next);
};
@@ -108,13 +130,6 @@ export class KubernetesProxy {
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,
@@ -146,19 +161,18 @@ export class KubernetesProxy {
request: { method: req.method, url: req.originalUrl },
response: { statusCode: 500 },
};
res.status(500).json(body);
},
onProxyReq: (proxyReq, req) => {
// the kubernetes proxy endpoint expects a header field labeled `Backstage-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an bearer token for the target cluster.
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
proxyReq.setHeader('Authorization', token);
if (req.header(HEADER_KUBERNETES_AUTH)) {
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
proxyReq.setHeader('Authorization', token);
}
},
});
this.middlewareForClusterName.set(originalCluster.name, middleware);
}
return middleware;
}
@@ -21,5 +21,8 @@ export {
KubernetesProxy,
HEADER_KUBERNETES_AUTH,
} from './KubernetesProxy';
export type { KubernetesProxyCreateRequestHandlerOptions } from './KubernetesProxy';
export type {
KubernetesProxyCreateRequestHandlerOptions,
KubernetesProxyOptions,
} from './KubernetesProxy';
export * from './router';