Merge pull request #13026 from liamrathke/poc-k8s-proxy-2

Kubernetes Backstage Plugin - Kubernetes Proxy API Route
This commit is contained in:
Fredrik Adelöw
2022-11-22 15:27:16 +01:00
committed by GitHub
11 changed files with 466 additions and 46 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).
+35
View File
@@ -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)
+6
View File
@@ -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": [
+3 -16
View File
@@ -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';
+16 -1
View File
@@ -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: