add proxy method with tests for kubernetesbackendclient
Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
@@ -184,6 +184,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
|
||||
decorateRequestBodyForAuth(
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody>;
|
||||
// (undocumented)
|
||||
getBearerToken(): Promise<string>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "GroupedResponses" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -259,6 +261,12 @@ export interface KubernetesApi {
|
||||
getWorkloadsByEntity(
|
||||
request: WorkloadsByEntityRequest,
|
||||
): Promise<ObjectsByEntityResponse>;
|
||||
// (undocumented)
|
||||
proxy(options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "kubernetesApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -281,6 +289,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
|
||||
authProvider: string,
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody>;
|
||||
// (undocumented)
|
||||
getBearerToken(authProvider: string): Promise<string>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "KubernetesAuthProvidersApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -292,6 +302,8 @@ export interface KubernetesAuthProvidersApi {
|
||||
authProvider: string,
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody>;
|
||||
// (undocumented)
|
||||
getBearerToken(authProvider: string): Promise<string>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "kubernetesAuthProvidersApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -306,6 +318,7 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
|
||||
});
|
||||
// (undocumented)
|
||||
getClusters(): Promise<
|
||||
@@ -326,6 +339,12 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
getWorkloadsByEntity(
|
||||
request: WorkloadsByEntityRequest,
|
||||
): Promise<ObjectsByEntityResponse>;
|
||||
// (undocumented)
|
||||
proxy(options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response>;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "KubernetesContentProps" needs to be exported by the entry point index.d.ts
|
||||
@@ -416,6 +435,8 @@ export class ServerSideKubernetesAuthProvider
|
||||
decorateRequestBodyForAuth(
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody>;
|
||||
// (undocumented)
|
||||
getBearerToken(): Promise<string>;
|
||||
}
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "ServicesAccordionsProps" needs to be exported by the entry point index.d.ts
|
||||
|
||||
@@ -106,6 +106,16 @@ class MockKubernetesClient implements KubernetesApi {
|
||||
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
|
||||
return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }];
|
||||
}
|
||||
|
||||
async proxy(_options: { clusterName: String; path: String }): Promise<any> {
|
||||
return {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'mock-ns',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
createDevApp()
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2023 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 { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider';
|
||||
import { KubernetesBackendClient } from './KubernetesBackendClient';
|
||||
import { rest } from 'msw';
|
||||
import { UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import {
|
||||
CustomObjectsByEntityRequest,
|
||||
KubernetesRequestBody,
|
||||
ObjectsByEntityResponse,
|
||||
WorkloadsByEntityRequest,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
describe('KubernetesBackendClient', () => {
|
||||
let backendClient: KubernetesBackendClient;
|
||||
const kubernetesAuthProvidersApi: jest.Mocked<KubernetesAuthProvidersApi> = {
|
||||
decorateRequestBodyForAuth: jest.fn(),
|
||||
getBearerToken: jest.fn(),
|
||||
};
|
||||
let mockResponse: ObjectsByEntityResponse;
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
const identityApi = {
|
||||
getCredentials: jest.fn(),
|
||||
getProfileInfo: jest.fn(),
|
||||
getBackstageIdentity: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
backendClient = new KubernetesBackendClient({
|
||||
discoveryApi: UrlPatternDiscovery.compile(
|
||||
'http://localhost:1234/api/{{ pluginId }}',
|
||||
),
|
||||
identityApi,
|
||||
kubernetesAuthProvidersApi,
|
||||
});
|
||||
mockResponse = {
|
||||
items: [
|
||||
{
|
||||
cluster: {
|
||||
name: 'cluster-a',
|
||||
},
|
||||
resources: [{ type: 'pods', resources: [] }],
|
||||
podMetrics: [
|
||||
{
|
||||
pod: {},
|
||||
cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
|
||||
memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
|
||||
containers: [
|
||||
{
|
||||
container: 'test',
|
||||
cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
|
||||
memoryUsage: {
|
||||
currentUsage: 8,
|
||||
requestTotal: 2,
|
||||
limitTotal: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('hits the /clusters API', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
|
||||
res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })),
|
||||
),
|
||||
);
|
||||
|
||||
const clusters = await backendClient.getClusters();
|
||||
|
||||
expect(clusters).toStrictEqual([
|
||||
{ name: 'cluster-a', authProvider: 'aws' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hits the /resources/custom/query API', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
rest.post(
|
||||
'http://localhost:1234/api/kubernetes/resources/custom/query',
|
||||
(_, res, ctx) => res(ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const request: CustomObjectsByEntityRequest = {
|
||||
auth: {},
|
||||
customResources: [
|
||||
{
|
||||
group: 'test-group',
|
||||
apiVersion: 'v1',
|
||||
plural: 'none',
|
||||
},
|
||||
],
|
||||
entity: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'pod',
|
||||
metadata: {
|
||||
name: 'test-name',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const customObject: ObjectsByEntityResponse =
|
||||
await backendClient.getCustomObjectsByEntity(request);
|
||||
|
||||
expect(customObject).toStrictEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('hits the /services/{entityName} api', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
rest.post(
|
||||
'http://localhost:1234/api/kubernetes/services/test-name',
|
||||
(_, res, ctx) => res(ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const request: KubernetesRequestBody = {
|
||||
entity: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'pod',
|
||||
metadata: {
|
||||
name: 'test-name',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const entityObject: ObjectsByEntityResponse =
|
||||
await backendClient.getObjectsByEntity(request);
|
||||
|
||||
expect(entityObject).toStrictEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('hits the /resources/workloads/query API', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
rest.post(
|
||||
'http://localhost:1234/api/kubernetes/resources/workloads/query',
|
||||
(_, res, ctx) => res(ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const request: WorkloadsByEntityRequest = {
|
||||
auth: {},
|
||||
entity: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'pod',
|
||||
metadata: {
|
||||
name: 'test-name',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response: ObjectsByEntityResponse =
|
||||
await backendClient.getWorkloadsByEntity(request);
|
||||
|
||||
expect(response).toStrictEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('hits the /proxy API', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue(
|
||||
'Bearer k8-token',
|
||||
);
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'new-ns',
|
||||
},
|
||||
};
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(_, res, ctx) => res(ctx.json(nsResponse)),
|
||||
),
|
||||
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
|
||||
res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })),
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
clusterName: 'cluster-a',
|
||||
path: '/api/v1/namespaces',
|
||||
};
|
||||
|
||||
const response = await backendClient.proxy(request);
|
||||
|
||||
expect(response).toStrictEqual(nsResponse);
|
||||
});
|
||||
|
||||
it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
worker.use(
|
||||
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
|
||||
res(ctx.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })),
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
clusterName: 'cluster-a',
|
||||
path: '/api/v1/namespaces',
|
||||
};
|
||||
|
||||
await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('hits /proxy api when signed in as a guest', async () => {
|
||||
// when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value.
|
||||
identityApi.getCredentials.mockResolvedValue({});
|
||||
kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue(
|
||||
'Bearer k8-token',
|
||||
);
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'new-ns',
|
||||
},
|
||||
};
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)),
|
||||
),
|
||||
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
|
||||
res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })),
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
clusterName: 'cluster-a',
|
||||
path: '/api/v1/namespaces',
|
||||
};
|
||||
|
||||
const response = await backendClient.proxy(request);
|
||||
expect(response).toStrictEqual(nsResponse);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,23 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async getCluster(
|
||||
clusterName: string,
|
||||
): Promise<{ name: string; authProvider: string }> {
|
||||
const cluster = await this.getClusters().then(clusters =>
|
||||
clusters.find(c => c.name === clusterName),
|
||||
);
|
||||
if (!cluster) {
|
||||
throw new NotFoundError(`Cluster ${clusterName} not found`);
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
private async getBearerToken(authProvider: string): Promise<string> {
|
||||
return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider);
|
||||
}
|
||||
|
||||
async getObjectsByEntity(
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<ObjectsByEntityResponse> {
|
||||
@@ -105,7 +122,6 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
|
||||
const { token: idToken } = await this.identityApi.getCredentials();
|
||||
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -116,67 +132,31 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
return (await this.handleResponse(response)).items;
|
||||
}
|
||||
|
||||
async proxy(
|
||||
clusterName: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const { authProvider } = await this.getCluster(clusterName);
|
||||
const token = await this.getBearerToken(authProvider);
|
||||
async proxy(options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response> {
|
||||
const { authProvider } = await this.getCluster(options.clusterName);
|
||||
const k8sToken = await this.getBearerToken(authProvider);
|
||||
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${
|
||||
options.path
|
||||
}`;
|
||||
const identityResponse = await this.identityApi.getCredentials();
|
||||
const headers = identityResponse.token
|
||||
? {
|
||||
...options.init?.headers,
|
||||
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
|
||||
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
|
||||
Authorization: `Bearer ${identityResponse.token}`,
|
||||
}
|
||||
: {
|
||||
...options.init?.headers,
|
||||
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
|
||||
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
|
||||
};
|
||||
const response = await fetch(url, { ...options.init, headers });
|
||||
|
||||
const url = `${await this.discoveryApi.getBaseUrl(
|
||||
'kubernetes',
|
||||
)}/proxy${path}`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...init?.headers,
|
||||
[`X-Kubernetes-Cluster`]: clusterName,
|
||||
};
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async getCluster(
|
||||
clusterName: string,
|
||||
): Promise<{ name: string; authProvider: string }> {
|
||||
const cluster = await this.getClusters().then(clusters =>
|
||||
clusters.find(c => c.name === clusterName),
|
||||
);
|
||||
if (!cluster) {
|
||||
throw new NotFoundError(`Cluster ${clusterName} not found`);
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
private async getBearerToken(authProvider: string): Promise<string> {
|
||||
// const { auth } =
|
||||
// await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
|
||||
// authProvider,
|
||||
// {
|
||||
// entity: {
|
||||
// apiVersion: 'v1',
|
||||
// kind: 'Pods',
|
||||
// metadata: {
|
||||
// name: 'podName',
|
||||
// annotations: {
|
||||
// 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider);
|
||||
|
||||
// if (auth) {
|
||||
// if (authProvider === 'google' && auth.google) {
|
||||
// return auth.google;
|
||||
// } else if (authProvider.startsWith('oidc.') && auth.oidc) {
|
||||
// const oidcTokenProvider = authProvider.replace(/^oidc\./, '');
|
||||
// return auth.oidc[oidcTokenProvider];
|
||||
// } else {
|
||||
// throw new Error(`invalid auth provider '${authProvider}'`);
|
||||
// }
|
||||
// }
|
||||
// return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ export interface KubernetesApi {
|
||||
getCustomObjectsByEntity(
|
||||
request: CustomObjectsByEntityRequest,
|
||||
): Promise<ObjectsByEntityResponse>;
|
||||
proxy(
|
||||
clusterName: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response>;
|
||||
proxy(options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response>;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
|
||||
async decorateRequestBodyForAuth(
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody> {
|
||||
const googleAuthToken: string = await this.authProvider.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
);
|
||||
const googleAuthToken: string = await this.getBearerToken();
|
||||
if ('auth' in requestBody) {
|
||||
requestBody.auth!.google = googleAuthToken;
|
||||
} else {
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
KubernetesRequestAuth,
|
||||
KubernetesRequestBody,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
|
||||
import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types';
|
||||
import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider';
|
||||
import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider';
|
||||
|
||||
@@ -30,7 +30,7 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider {
|
||||
async decorateRequestBodyForAuth(
|
||||
requestBody: KubernetesRequestBody,
|
||||
): Promise<KubernetesRequestBody> {
|
||||
const authToken: string = await this.authProvider.getIdToken();
|
||||
const authToken: string = await this.getBearerToken();
|
||||
const auth = { ...requestBody.auth };
|
||||
if (auth.oidc) {
|
||||
auth.oidc[this.providerName] = authToken;
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
KubernetesRequestAuth,
|
||||
KubernetesRequestBody,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export interface KubernetesAuthProvider {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { TextDecoder, TextEncoder } from 'util';
|
||||
import 'cross-fetch/polyfill';
|
||||
|
||||
// These are missing from jest-node, so not available on global.
|
||||
Object.defineProperty(global, 'TextEncoder', {
|
||||
|
||||
Reference in New Issue
Block a user