feat: Remove multi-cluster support per latest RFC version

Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com>
This commit is contained in:
Carlos Esteban Lopez
2022-11-01 14:28:58 -05:00
committed by Fredrik Adelöw
parent e862980c57
commit 9fb0d696b8
6 changed files with 159 additions and 396 deletions
+12 -19
View File
@@ -22,10 +22,12 @@ import { Logger } from 'winston';
import { Metrics } from '@kubernetes/client-node';
import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PodStatus } from '@kubernetes/client-node/dist/top';
import type { Request as Request_2 } from 'express';
import type { RequestHandler } from 'express';
import { TokenCredential } from '@azure/identity';
// @alpha (undocumented)
export const APPLICATION_JSON: string;
// @alpha (undocumented)
export interface AWSClusterDetails extends ClusterDetails {
// (undocumented)
@@ -144,6 +146,9 @@ export class GoogleServiceAccountAuthTranslator
): Promise<GKEClusterDetails>;
}
// @alpha (undocumented)
export const HEADER_KUBERNETES_CLUSTER: string;
// @alpha (undocumented)
export interface KubernetesAuthTranslator {
// (undocumented)
@@ -227,11 +232,6 @@ export class KubernetesBuilder {
// (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;
@@ -251,6 +251,7 @@ export type KubernetesBuilderReturn = Promise<{
clusterSupplier: KubernetesClustersSupplier;
customResources: CustomResource[];
fetcher: KubernetesFetcher;
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
}>;
@@ -349,22 +350,14 @@ export type KubernetesObjectTypes =
export class KubernetesProxy {
constructor(logger: Logger);
// (undocumented)
handleProxyRequest(
req: Request_2,
clusterSupplier: KubernetesClustersSupplier,
): Promise<KubernetesProxyResponse>;
get clustersSupplier(): KubernetesClustersSupplier;
set clustersSupplier(clustersSupplier: KubernetesClustersSupplier);
// (undocumented)
protected readonly logger: Logger;
}
// @alpha (undocumented)
export interface KubernetesProxyResponse {
// (undocumented)
cluster?: string;
static readonly PROXY_PATH: string;
// (undocumented)
code: number;
// (undocumented)
data: any;
proxyRequestHandler: RequestHandler;
}
// @alpha
+2 -1
View File
@@ -44,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",
@@ -58,7 +59,7 @@
"lodash": "^4.17.21",
"luxon": "^3.0.0",
"morgan": "^1.10.0",
"node-fetch": "^2.6.0",
"node-fetch": "^2.6.7",
"stream-buffers": "^3.0.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
@@ -40,7 +40,7 @@ import {
KubernetesFanOutHandler,
} from './KubernetesFanOutHandler';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy';
import { KubernetesProxy } from './KubernetesProxy';
/**
*
@@ -62,6 +62,7 @@ export type KubernetesBuilderReturn = Promise<{
clusterSupplier: KubernetesClustersSupplier;
customResources: CustomResource[];
fetcher: KubernetesFetcher;
proxy: KubernetesProxy;
objectsProvider: KubernetesObjectsProvider;
serviceLocator: KubernetesServiceLocator;
}>;
@@ -107,8 +108,12 @@ export class KubernetesBuilder {
const fetcher = this.getFetcher();
const proxy = this.getProxy();
const clusterSupplier = this.getClusterSupplier();
proxy.clustersSupplier = clusterSupplier;
const serviceLocator = this.getServiceLocator();
const objectsProvider = this.getObjectsProvider({
@@ -129,6 +134,7 @@ export class KubernetesBuilder {
clusterSupplier,
customResources,
fetcher,
proxy,
objectsProvider,
router,
serviceLocator,
@@ -292,12 +298,12 @@ export class KubernetesBuilder {
});
});
if (typeof proxy?.handleProxyRequest === 'function') {
router.get('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
router.post('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
router.put('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
router.patch('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
router.delete('/proxy/:encodedQuery', this.makeProxyRequest.bind(this));
if (typeof proxy?.proxyRequestHandler === 'function') {
router.get(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler);
router.post(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler);
router.put(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler);
router.patch(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler);
router.delete(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler);
}
addResourceRoutesToRouter(router, catalogApi, objectsProvider);
@@ -381,24 +387,4 @@ export class KubernetesBuilder {
protected getProxy() {
return this.proxy ?? this.buildProxy();
}
protected async makeProxyRequest(
req: express.Request,
res: express.Response,
) {
const supplier = this.getClustersSupplier();
const proxy = this.getProxy();
const proxyResponse: KubernetesProxyResponse =
await proxy.handleProxyRequest(req, supplier);
res.status(proxyResponse.code).json(proxyResponse.data);
}
private getClustersSupplier(): KubernetesClustersSupplier {
return (
this.clusterSupplier ??
this.buildClusterSupplier(this.defaultClusterRefreshInterval)
);
}
}
@@ -22,50 +22,38 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import { KubernetesProxy } from './KubernetesProxy';
import {
APPLICATION_JSON,
HEADER_KUBERNETES_CLUSTER,
KubernetesProxy,
} from './KubernetesProxy';
import { NotFoundError } from '@backstage/errors';
import { getMockReq, getMockRes } from '@jest-mock/express';
describe('KubernetesProxy', () => {
let proxy: KubernetesProxy;
const worker = setupServer();
setupRequestMockHandlers(worker);
const buildEncodedRequest = (
clustersHeader: any,
query: string,
body?: unknown,
): Request => {
const encodedQuery = encodeURIComponent(query);
const encodedClusters = Buffer.from(
JSON.stringify(clustersHeader),
).toString('base64');
const req = {
const buildMockRequest = (clusterName: any, path: string): Request => {
const req = getMockReq({
params: {
encodedQuery,
path,
},
header: (key: string) => {
let value: string = '';
header: jest.fn((key: string) => {
switch (key) {
case 'Content-Type': {
value = 'application/json';
break;
return APPLICATION_JSON;
}
case 'X-Kubernetes-Clusters': {
value = encodedClusters;
break;
case HEADER_KUBERNETES_CLUSTER: {
return clusterName;
}
default: {
break;
return '';
}
}
return value;
},
} as unknown as Request;
if (body) {
req.body = body;
}
}),
});
return req;
};
@@ -83,15 +71,17 @@ describe('KubernetesProxy', () => {
});
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
const clustersSupplier = buildClustersSupplierWithClusters([]);
const req = buildEncodedRequest({}, 'api');
proxy.clustersSupplier = buildClustersSupplierWithClusters([]);
await expect(
proxy.handleProxyRequest(req, clustersSupplier),
).rejects.toThrow(NotFoundError);
const req = buildMockRequest('test', 'api');
const { res, next } = getMockRes();
await expect(proxy.proxyRequestHandler(req, res, next)).rejects.toThrow(
NotFoundError,
);
});
it('should match the response code of the Kubernetes response (single cluster)', async () => {
it('should match the response code of the Kubernetes response', async () => {
const clusters: ClusterDetails[] = [
{
name: 'cluster1',
@@ -102,8 +92,10 @@ describe('KubernetesProxy', () => {
},
];
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters);
const req = buildMockRequest('cluster1', 'api');
const { res: response, next } = getMockRes();
const apiResponse = {
kind: 'APIVersions',
@@ -117,76 +109,18 @@ describe('KubernetesProxy', () => {
};
worker.use(
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) =>
res(ctx.status(299), ctx.body(JSON.stringify(apiResponse))),
),
);
const result = await proxy.handleProxyRequest(req, clustersSupplier);
await proxy.proxyRequestHandler(req, response, next);
expect(result.code).toEqual(299);
expect(response.status).toHaveBeenCalledWith(299);
expect(response.json).toHaveBeenCalledWith(apiResponse);
});
it('should match the response code of the best Kubernetes response (multi cluster)', async () => {
const clusters: ClusterDetails[] = [
{
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 clustersSupplier = buildClustersSupplierWithClusters(clusters);
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,
};
worker.use(
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))),
),
rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))),
),
);
const result = await proxy.handleProxyRequest(req, clustersSupplier);
expect(result.code).toEqual(200);
});
it('should pass the exact response data from Kubernetes (single cluster)', async () => {
it('should pass the exact response data from Kubernetes', async () => {
const clusters: ClusterDetails[] = [
{
name: 'cluster1',
@@ -197,8 +131,10 @@ describe('KubernetesProxy', () => {
},
];
const clustersSupplier = buildClustersSupplierWithClusters(clusters);
const req = buildEncodedRequest({ cluster1: 'token' }, 'api');
proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters);
const req = buildMockRequest('cluster1', 'api');
const { res: response, next } = getMockRes();
const apiResponse = {
kind: 'APIVersions',
@@ -212,109 +148,14 @@ describe('KubernetesProxy', () => {
};
worker.use(
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) =>
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse))),
),
);
const result = await proxy.handleProxyRequest(req, clustersSupplier);
await proxy.proxyRequestHandler(req, response, next);
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(resultString).toEqual(expectedString);
});
it('should pass the exact response data from Kubernetes (multi cluster)', async () => {
const clusters: ClusterDetails[] = [
{
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 clustersSupplier = buildClustersSupplierWithClusters(clusters);
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,
};
worker.use(
rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))),
),
rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) =>
res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))),
),
);
const result = await proxy.handleProxyRequest(req, clustersSupplier);
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(resultString).toEqual(expectedString);
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith(apiResponse);
});
});
@@ -19,151 +19,105 @@ import {
ForwardedError,
InputError,
NotFoundError,
stringifyError,
} from '@backstage/errors';
import { bufferFromFileOrString, KubeConfig } from '@kubernetes/client-node';
import * as https from 'https';
import fetch, { RequestInit } from 'node-fetch';
import fetch, { RequestInit, Response } from 'node-fetch';
import { Logger } from 'winston';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import type { Request } from 'express';
import type { Request as ExpressRequest, RequestHandler } from 'express';
/**
*
* @alpha
*/
export const APPLICATION_JSON: string = 'application/json';
/**
*
* @alpha
*/
export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster';
const HEADER_CONTENT_TYPE: string = 'Content-Type';
const APPLICATION_JSON: string = 'application/json';
const HEADER_KUBERNETES_CLUSTERS: string = 'X-Kubernetes-Clusters';
const ERROR_INTERNAL_SERVER: number = 500;
const CLUSTER_USER_NAME: string = 'backstage';
/**
*
* @alpha
*/
export interface KubernetesProxyResponse {
code: number;
data: any;
cluster?: string;
}
/**
*
* @alpha
*/
interface KubernetesProxyClusters {
[key: string]: string;
}
/**
*
* @alpha
*/
export class KubernetesProxy {
private _clustersSupplier?: KubernetesClustersSupplier;
static readonly PROXY_PATH: string = '/proxy/:path(*)';
constructor(protected readonly logger: Logger) {}
public async handleProxyRequest(
req: Request,
clusterSupplier: KubernetesClustersSupplier,
): Promise<KubernetesProxyResponse> {
const requestedClusters = this.getKubernetesRequestedClusters(req);
public proxyRequestHandler: RequestHandler = async (req, res) => {
const requestedCluster = this.getKubernetesRequestedCluster(req);
if (Object.keys(requestedClusters).length < 1) {
this.logger.error(`No clusters found`);
throw new NotFoundError('No clusters found!');
const clusterDetails = await this.getClusterDetails(requestedCluster);
const response = await this.makeRequestToCluster(clusterDetails, req);
const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON;
const data = contentType.includes(APPLICATION_JSON)
? await response.json()
: await response.text();
res.status(response.status).json(data);
};
public get clustersSupplier(): KubernetesClustersSupplier {
if (this._clustersSupplier ? false : this._clustersSupplier ?? true) {
throw new ConflictError("Missing Proxy's Clusters Supplier");
}
const clusterDetails = await this.getClusterDetails(
clusterSupplier,
requestedClusters,
);
if (clusterDetails.length < 1) {
this.logger.error(`No clusters found`);
throw new NotFoundError('No clusters found!');
}
const responses = await Promise.all(
clusterDetails.map(async clusterDetail => {
const response = await this.makeRequestToCluster(clusterDetail, 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;
return this._clustersSupplier as KubernetesClustersSupplier;
}
private getKubernetesRequestedClusters(
req: Request,
): KubernetesProxyClusters {
const encodedClusters: string =
req.header(HEADER_KUBERNETES_CLUSTERS) ?? '';
public set clustersSupplier(clustersSupplier) {
this._clustersSupplier = clustersSupplier;
}
if (!encodedClusters) {
return {};
private getKubernetesRequestedCluster(req: ExpressRequest): string {
const requestedClusterName: string =
req.header(HEADER_KUBERNETES_CLUSTER) ?? '';
if (!requestedClusterName) {
this.logger.error(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`);
throw new InputError(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`);
}
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: ${stringifyError(e)}`,
);
}
return {};
return requestedClusterName;
}
private async getClusterDetails(
clusterSupplier: KubernetesClustersSupplier,
requestedClusters: KubernetesProxyClusters,
): Promise<ClusterDetails[]> {
const clusters = await clusterSupplier.getClusters();
requestedCluster: string,
): Promise<ClusterDetails> {
const clusters = await this.clustersSupplier.getClusters();
const clusterNames = Object.keys(requestedClusters);
const clusterDetail = clusters.find(cluster =>
requestedCluster.includes(cluster.name),
);
const clusterDetails = clusters.filter(c => clusterNames.includes(c.name));
if (clusterDetail ? false : clusterDetail ?? true) {
this.logger.error(
`Cluster ${requestedCluster} details not found in config`,
);
const clusterDetailsAuth = clusterDetails.map(c => {
const cAuth: ClusterDetails = Object.assign(c, {
serviceAccountToken: requestedClusters[c.name],
});
return cAuth;
});
throw new NotFoundError("Cluster's detail not found");
}
return clusterDetailsAuth;
return clusterDetail as ClusterDetails;
}
private getClusterURI(details: ClusterDetails): string {
const client = this.getKubeConfig(details);
return client.getCurrentCluster()?.server || '';
}
private async makeRequestToCluster(
details: ClusterDetails,
req: Request,
): Promise<KubernetesProxyResponse> {
const serverURI = this.getClusterURI(details);
const serverURI = this.getKubeConfig(details)?.getCurrentCluster()?.server;
if (!serverURI) {
this.logger.error(`Cluster ${details.name} details IP error`);
@@ -171,27 +125,26 @@ export class KubernetesProxy {
throw new ConflictError('Cluster detail error');
}
const query = decodeURIComponent(req.params.encodedQuery) || '';
const uri = `${serverURI}/${query}`;
return serverURI;
}
const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON;
private async makeRequestToCluster(
details: ClusterDetails,
req: ExpressRequest,
): Promise<Response> {
const serverURI = this.getClusterURI(details);
return await this.sendClusterRequest(
details,
uri,
req.method,
contentType,
req.body,
);
const path = decodeURIComponent(req.params.path) || '';
const uri = `${serverURI}/${path}`;
return await this.sendClusterRequest(details, uri, req);
}
private async sendClusterRequest(
details: ClusterDetails,
uri: string,
method: string,
contentType: string,
body?: any,
): Promise<KubernetesProxyResponse> {
req: ExpressRequest,
): Promise<Response> {
const bearerToken = details.serviceAccountToken;
if (!bearerToken) {
@@ -200,12 +153,11 @@ export class KubernetesProxy {
throw new AuthenticationError('Invalid service account token');
}
const { method, headers, body } = req;
const reqData: RequestInit = {
method,
headers: {
'Content-Type': contentType,
Authorization: `Bearer ${bearerToken}`,
},
headers: headers as { [key: string]: string },
};
if (!details.skipTLSVerify) {
@@ -214,6 +166,7 @@ export class KubernetesProxy {
reqData.agent = new https.Agent({ ca });
} else {
this.logger.error('could not find CA certificate!');
throw new InputError(
'Invalid CA certificate configured within Backstage',
);
@@ -225,23 +178,7 @@ export class KubernetesProxy {
}
try {
const res = await fetch(uri, reqData);
let data: string | any;
if (contentType.includes(APPLICATION_JSON)) {
data = await res.json();
} else {
data = await res.text();
}
const proxyResponse: KubernetesProxyResponse = {
code: res.status,
data,
cluster: details.name,
};
return proxyResponse;
return fetch(uri, reqData);
} catch (e: any) {
throw new ForwardedError(`Cluster ${details.name} request error`, e);
}
@@ -281,9 +218,4 @@ export class KubernetesProxy {
return kubeConfig;
}
private getBestResponseCode(codes: number[]): number {
const sorted = codes.sort();
return sorted[0] ?? ERROR_INTERNAL_SERVER;
}
}
+11 -1
View File
@@ -5973,6 +5973,7 @@ __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
@@ -5990,7 +5991,7 @@ __metadata:
luxon: ^3.0.0
morgan: ^1.10.0
msw: ^0.48.0
node-fetch: ^2.6.0
node-fetch: ^2.6.7
stream-buffers: ^3.0.2
supertest: ^6.1.3
winston: ^3.2.1
@@ -9250,6 +9251,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"