feat(kubernetes): Add custom resource fetch hook (#13736)

* feat(kubernetes): wip crd hook

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* refactor hooks

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix tests

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* api reports

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* typo

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2022-09-21 14:05:05 -04:00
committed by GitHub
parent 410759679a
commit 51af8361de
11 changed files with 628 additions and 216 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes': patch
---
Add useCustomResources react hook for fetching Kubernetes Custom Resources
+10
View File
@@ -11,6 +11,7 @@ import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
import { ClusterAttributes } from '@backstage/plugin-kubernetes-common';
import { ClusterObjects } from '@backstage/plugin-kubernetes-common';
import { CustomObjectsByEntityRequest } from '@backstage/plugin-kubernetes-common';
import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { IdentityApi } from '@backstage/core-plugin-api';
@@ -358,6 +359,8 @@ export interface KubernetesObjects {
error?: string;
// (undocumented)
kubernetesObjects?: ObjectsByEntityResponse;
// (undocumented)
loading: boolean;
}
// Warning: (ae-missing-release-tag) "kubernetesPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -421,6 +424,13 @@ export class ServerSideKubernetesAuthProvider
// @public (undocumented)
export const ServicesAccordions: ({}: ServicesAccordionsProps) => JSX.Element;
// @public
export const useCustomResources: (
entity: Entity,
customResourceMatchers: CustomResourceMatcher[],
intervalMs?: number,
) => KubernetesObjects;
// Warning: (ae-missing-release-tag) "useKubernetesObjects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+20 -2
View File
@@ -64,12 +64,30 @@ class MockKubernetesClient implements KubernetesApi {
async getWorkloadsByEntity(
_request: WorkloadsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
throw new Error('Method not implemented.');
return {
items: [
{
cluster: { name: 'mock-cluster' },
resources: this.resources,
podMetrics: [],
errors: [],
},
],
};
}
async getCustomObjectsByEntity(
_request: CustomObjectsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
throw new Error('Method not implemented.');
return {
items: [
{
cluster: { name: 'mock-cluster' },
resources: this.resources,
podMetrics: [],
errors: [],
},
],
};
}
async getObjectsByEntity(): Promise<ObjectsByEntityResponse> {
+126
View File
@@ -0,0 +1,126 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { generateAuth } from './auth';
jest.mock('@backstage/core-plugin-api');
const entity = {
metadata: {
name: 'some-entity',
},
} as Entity;
const entityWithAuthToken = {
auth: {
google: 'some-token',
},
entity,
};
const getClustersResponse = [
{
name: 'cluster-a',
authProvider: 'google',
},
{
name: 'cluster-b',
authProvider: 'authprovider2',
},
];
describe('generateAuth', () => {
const mockGetClusters = jest.fn();
const mockDecorateRequestBodyForAuth = jest.fn();
const expectMocksCalledCorrectly = (numOfCalls: number = 1) => {
expect(mockGetClusters).toHaveBeenCalledTimes(numOfCalls);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(
numOfCalls * 2,
);
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', {
entity,
});
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith(
'authprovider2',
entityWithAuthToken,
);
};
afterEach(() => {
jest.resetAllMocks();
});
it('should return auth', async () => {
const result = await generateAuth(
entity,
{
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
} as any,
{
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
} as any,
);
expect(result).toStrictEqual({
google: 'some-token',
});
expectMocksCalledCorrectly();
});
it('should return error when getClusters throws', async () => {
await expect(
generateAuth(
entity,
{
getClusters: mockGetClusters.mockRejectedValue('some-cluster-error'),
} as any,
{
decorateRequestBodyForAuth: mockDecorateRequestBodyForAuth,
} as any,
),
).rejects.toBe('some-cluster-error');
expect(mockGetClusters).toHaveBeenCalledTimes(1);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(0);
});
it('should return error when decorateRequestBodyForAuth throws', async () => {
await expect(
generateAuth(
entity,
{
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
} as any,
{
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockRejectedValue(
'some-decorate-error',
),
} as any,
),
).rejects.toBe('some-decorate-error');
expect(mockGetClusters).toHaveBeenCalledTimes(1);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(1);
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', {
entity,
});
});
});
+50
View File
@@ -0,0 +1,50 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { KubernetesApi } from '../api/types';
import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export const generateAuth = async (
entity: Entity,
kubernetesApi: KubernetesApi,
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi,
) => {
const clusters = await kubernetesApi.getClusters();
const authProviders: string[] = [
...new Set(
clusters.map(
c =>
`${c.authProvider}${
c.oidcTokenProvider ? `.${c.oidcTokenProvider}` : ''
}`,
),
),
];
let requestBody: KubernetesRequestBody = {
entity,
};
for (const authProviderStr of authProviders) {
requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
authProviderStr,
requestBody,
);
}
return requestBody.auth ?? {};
};
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './useKubernetesObjects';
export * from './useCustomResources';
export * from './PodNamesWithErrors';
export * from './PodNamesWithMetrics';
export * from './GroupedResponses';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
export interface KubernetesObjects {
kubernetesObjects: ObjectsByEntityResponse;
loading: boolean;
error: string;
}
@@ -0,0 +1,256 @@
/*
* Copyright 2021 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 { useCustomResources } from './useCustomResources';
import { Entity } from '@backstage/catalog-model';
import { renderHook } from '@testing-library/react-hooks';
import { useApi } from '@backstage/core-plugin-api';
import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common';
import { generateAuth } from './auth';
jest.mock('@backstage/core-plugin-api');
const entity = {
metadata: {
name: 'some-entity',
},
} as Entity;
const customResourceMatchers: CustomResourceMatcher[] = [
{
group: 'myGroup',
apiVersion: 'v1',
plural: 'thing',
},
];
const entityWithAuthToken = {
auth: {
google: 'some-token',
},
customResources: customResourceMatchers,
entity,
};
const mockResponse = {
items: [
{
cluster: { name: 'some-cluster' },
resources: [
{
type: 'pods',
resources: [
{
metadata: {
name: 'some-pod',
},
},
],
},
],
errors: [],
},
],
};
jest.mock('./auth', () => {
return {
...jest.requireActual('./auth'),
generateAuth: jest.fn(),
};
});
describe('useCustomResources', () => {
const mockGetCustomObjectsByEntity = jest.fn();
const mockGenerateAuth = generateAuth as jest.Mock;
const expectMocksCalledCorrectly = (numOfCalls: number = 1) => {
expect(mockGenerateAuth).toHaveBeenCalledTimes(numOfCalls);
expect(mockGenerateAuth.mock.calls[numOfCalls - 1][0]).toStrictEqual(
entity,
);
expect(mockGetCustomObjectsByEntity).toHaveBeenCalledTimes(numOfCalls);
expect(mockGetCustomObjectsByEntity).toHaveBeenLastCalledWith(
entityWithAuthToken,
);
};
afterEach(() => {
jest.resetAllMocks();
});
it('should return objects', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getCustomObjectsByEntity:
mockGetCustomObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers),
);
expect(result.current.loading).toEqual(true);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toStrictEqual(mockResponse);
expectMocksCalledCorrectly();
});
it('should update on an interval', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getCustomObjectsByEntity:
mockGetCustomObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toStrictEqual(mockResponse);
expectMocksCalledCorrectly(2);
});
it('should return error when getObjectsByEntity throws', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getCustomObjectsByEntity: mockGetCustomObjectsByEntity.mockRejectedValue({
message: 'some error',
}),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers),
);
await waitForNextUpdate();
expect(result.current.error).toBe('some error');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
expectMocksCalledCorrectly();
});
describe('when retrying', () => {
it('should reset error after generateAuth has failed and then succeeded', async () => {
(useApi as any).mockReturnValue({
generateAuth: mockGenerateAuth
.mockRejectedValueOnce({ message: 'generateAuth failed' })
.mockResolvedValue(entityWithAuthToken.auth),
getCustomObjectsByEntity:
mockGetCustomObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBe('generateAuth failed');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
});
it('should reset error after getCustomObjectsByEntity has failed and then succeeded', async () => {
(useApi as any).mockReturnValue({
getCustomObjectsByEntity: mockGetCustomObjectsByEntity
.mockRejectedValueOnce({ message: 'failed to fetch' })
.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBe('failed to fetch');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
});
it('should reset data after generateAuth succeeded then failed', async () => {
(useApi as any).mockReturnValue({
generateAuth: mockGenerateAuth
.mockResolvedValueOnce(entityWithAuthToken.auth)
.mockRejectedValue({ message: 'generateAuth failed' }),
getCustomObjectsByEntity:
mockGetCustomObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBe('generateAuth failed');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
});
it('should reset data after getCustomObjectsByEntity succeeded then failed', async () => {
(useApi as any).mockReturnValue({
getCustomObjectsByEntity: mockGetCustomObjectsByEntity
.mockResolvedValueOnce(mockResponse)
.mockRejectedValue({ message: 'failed to fetch' }),
});
const { result, waitForNextUpdate } = renderHook(() =>
useCustomResources(entity, customResourceMatchers, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBe('failed to fetch');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
});
});
});
@@ -0,0 +1,75 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { kubernetesApiRef } from '../api/types';
import { kubernetesAuthProvidersApiRef } from '../kubernetes-auth-provider/types';
import { useCallback } from 'react';
import useInterval from 'react-use/lib/useInterval';
import {
CustomResourceMatcher,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { useApi } from '@backstage/core-plugin-api';
import { KubernetesObjects } from './useKubernetesObjects';
import { generateAuth } from './auth';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
/**
* Retrieves the provided custom resources related to the provided entity, refreshes at an interval.
*
* @public
*/
export const useCustomResources = (
entity: Entity,
customResourceMatchers: CustomResourceMatcher[],
intervalMs: number = 10000,
): KubernetesObjects => {
const kubernetesApi = useApi(kubernetesApiRef);
const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
const getCustomObjects =
useCallback(async (): Promise<ObjectsByEntityResponse> => {
const auth = await generateAuth(
entity,
kubernetesApi,
kubernetesAuthProvidersApi,
);
return await kubernetesApi.getCustomObjectsByEntity({
auth,
customResources: customResourceMatchers,
entity,
});
}, [
kubernetesApi,
entity,
kubernetesAuthProvidersApi,
customResourceMatchers,
]);
const { value, loading, error, retry } = useAsyncRetry(
() => getCustomObjects(),
[getCustomObjects],
);
useInterval(() => retry(), intervalMs);
return {
kubernetesObjects: value,
loading,
error: error?.message,
};
};
@@ -18,6 +18,7 @@ import { useKubernetesObjects } from './useKubernetesObjects';
import { Entity } from '@backstage/catalog-model';
import { renderHook } from '@testing-library/react-hooks';
import { useApi } from '@backstage/core-plugin-api';
import { generateAuth } from './auth';
jest.mock('@backstage/core-plugin-api');
@@ -55,34 +56,21 @@ const mockResponse = {
],
};
const getClustersResponse = [
{
name: 'cluster-a',
authProvider: 'google',
},
{
name: 'cluster-b',
authProvider: 'authprovider2',
},
];
jest.mock('./auth', () => {
return {
...jest.requireActual('./auth'),
generateAuth: jest.fn(),
};
});
describe('useKubernetesObjects', () => {
const mockGetClusters = jest.fn();
const mockGetObjectsByEntity = jest.fn();
const mockDecorateRequestBodyForAuth = jest.fn();
const mockGenerateAuth = generateAuth as jest.Mock;
const expectMocksCalledCorrectly = (numOfCalls: number = 1) => {
expect(mockGetClusters).toHaveBeenCalledTimes(numOfCalls);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(
numOfCalls * 2,
);
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', {
expect(mockGenerateAuth).toHaveBeenCalledTimes(numOfCalls);
expect(mockGenerateAuth.mock.calls[numOfCalls - 1][0]).toStrictEqual(
entity,
});
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith(
'authprovider2',
entityWithAuthToken,
);
expect(mockGetObjectsByEntity).toHaveBeenCalledTimes(numOfCalls);
expect(mockGetObjectsByEntity).toHaveBeenLastCalledWith(
@@ -94,31 +82,30 @@ describe('useKubernetesObjects', () => {
jest.resetAllMocks();
});
it('should return objects', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity),
);
expect(result.current.loading).toEqual(true);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toStrictEqual(mockResponse);
expectMocksCalledCorrectly();
});
it('should update on an interval', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity, 100),
@@ -130,18 +117,17 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toStrictEqual(mockResponse);
expectMocksCalledCorrectly(2);
});
it('should return error when getObjectsByEntity throws', async () => {
mockGenerateAuth.mockResolvedValue(entityWithAuthToken.auth);
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
getObjectsByEntity: mockGetObjectsByEntity.mockRejectedValue({
message: 'some error',
}),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity),
@@ -150,66 +136,18 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBe('some error');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
expectMocksCalledCorrectly();
});
it('should return error when getClusters throws', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockRejectedValue({ message: 'some-error' }),
getObjectsByEntity: mockGetObjectsByEntity,
decorateRequestBodyForAuth: mockDecorateRequestBodyForAuth,
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity),
);
await waitForNextUpdate();
expect(result.current.error).toBe('some-error');
expect(result.current.kubernetesObjects).toBeUndefined();
expect(mockGetClusters).toHaveBeenCalledTimes(1);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(0);
expect(mockGetObjectsByEntity).toHaveBeenCalledTimes(0);
});
it('should return error when decorateRequestBodyForAuth throws', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockRejectedValue({
message: 'some-error',
}),
getObjectsByEntity: mockGetObjectsByEntity,
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity),
);
await waitForNextUpdate();
expect(result.current.error).toBe('some-error');
expect(result.current.kubernetesObjects).toBeUndefined();
expect(mockGetClusters).toHaveBeenCalledTimes(1);
expect(mockGetClusters).toHaveBeenLastCalledWith();
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledTimes(1);
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', {
entity,
});
expect(mockGetObjectsByEntity).toHaveBeenCalledTimes(0);
});
describe('when retrying', () => {
it('should reset error after getClusters has failed and then succeeded', async () => {
it('should reset error after generateAuth has failed and then succeeded', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters
.mockRejectedValueOnce({ message: 'some-error' })
.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
generateAuth: mockGenerateAuth
.mockRejectedValueOnce({ message: 'generateAuth failed' })
.mockResolvedValue(entityWithAuthToken.auth),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
});
@@ -220,45 +158,19 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBe('some-error');
expect(result.current.kubernetesObjects).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.kubernetesObjects).not.toBeUndefined();
});
it('should reset error after decorateRequestBodyForAuth has failed and then succeeded', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth: mockDecorateRequestBodyForAuth
.mockRejectedValueOnce({ message: 'decoration failed' })
.mockResolvedValue(entityWithAuthToken),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBe('decoration failed');
expect(result.current.error).toBe('generateAuth failed');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
});
it('should reset error after getObjectsByEntity has failed and then succeeded', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
getObjectsByEntity: mockGetObjectsByEntity
.mockRejectedValueOnce({ message: 'failed to fetch' })
.mockResolvedValue(mockResponse),
@@ -271,47 +183,21 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBe('failed to fetch');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
});
it('should reset data after getClusters succeeded then failed', async () => {
it('should reset data after generateAuth succeeded then failed', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters
.mockResolvedValueOnce(getClustersResponse)
.mockRejectedValue({ message: 'fetch clusters failed' }),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
});
const { result, waitForNextUpdate } = renderHook(() =>
useKubernetesObjects(entity, 100),
);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.kubernetesObjects).not.toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBe('fetch clusters failed');
expect(result.current.kubernetesObjects).toBeUndefined();
});
it('should reset data after decorateBodyForAuth succeeded then failed', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth: mockDecorateRequestBodyForAuth
// this call happens twice per successful hook render
.mockResolvedValueOnce(entityWithAuthToken)
.mockResolvedValueOnce(entityWithAuthToken)
.mockRejectedValue({ message: 'decorate failed' }),
generateAuth: mockGenerateAuth
.mockResolvedValueOnce(entityWithAuthToken.auth)
.mockRejectedValue({ message: 'generateAuth failed' }),
getObjectsByEntity:
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
});
@@ -323,19 +209,18 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBe('decorate failed');
expect(result.current.error).toBe('generateAuth failed');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
});
it('should reset data after getObjectsByEntity succeeded then failed', async () => {
(useApi as any).mockReturnValue({
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
decorateRequestBodyForAuth:
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
getObjectsByEntity: mockGetObjectsByEntity
.mockResolvedValueOnce(mockResponse)
.mockRejectedValue({ message: 'failed to fetch' }),
@@ -348,11 +233,13 @@ describe('useKubernetesObjects', () => {
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).not.toBeUndefined();
await waitForNextUpdate();
expect(result.current.error).toBe('failed to fetch');
expect(result.current.loading).toEqual(false);
expect(result.current.kubernetesObjects).toBeUndefined();
});
});
@@ -17,16 +17,16 @@
import { Entity } from '@backstage/catalog-model';
import { kubernetesApiRef } from '../api/types';
import { kubernetesAuthProvidersApiRef } from '../kubernetes-auth-provider/types';
import { useEffect, useState } from 'react';
import { useCallback } from 'react';
import useInterval from 'react-use/lib/useInterval';
import {
KubernetesRequestBody,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { useApi } from '@backstage/core-plugin-api';
import { generateAuth } from './auth';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
export interface KubernetesObjects {
kubernetesObjects?: ObjectsByEntityResponse;
loading: boolean;
error?: string;
}
@@ -36,68 +36,29 @@ export const useKubernetesObjects = (
): KubernetesObjects => {
const kubernetesApi = useApi(kubernetesApiRef);
const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
const [result, setResult] = useState<KubernetesObjects>({
kubernetesObjects: undefined,
error: undefined,
});
const getCustomObjects =
useCallback(async (): Promise<ObjectsByEntityResponse> => {
const auth = await generateAuth(
entity,
kubernetesApi,
kubernetesAuthProvidersApi,
);
return await kubernetesApi.getObjectsByEntity({
auth,
entity,
});
}, [kubernetesApi, entity, kubernetesAuthProvidersApi]);
const getObjects = async () => {
let clusters = [];
const { value, loading, error, retry } = useAsyncRetry(
() => getCustomObjects(),
[getCustomObjects],
);
try {
clusters = await kubernetesApi.getClusters();
} catch (e) {
setResult({ error: e.message });
return;
}
useInterval(() => retry(), intervalMs);
const authProviders: string[] = [
...new Set(
clusters.map(
c =>
`${c.authProvider}${
c.oidcTokenProvider ? `.${c.oidcTokenProvider}` : ''
}`,
),
),
];
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
let requestBody: KubernetesRequestBody = {
entity,
};
for (const authProviderStr of authProviders) {
// Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
try {
requestBody =
await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
authProviderStr,
requestBody,
);
} catch (e) {
setResult({ error: e.message });
return;
}
}
try {
const objects = await kubernetesApi.getObjectsByEntity(requestBody);
setResult({ kubernetesObjects: objects });
} catch (e) {
setResult({ error: e.message });
return;
}
return {
kubernetesObjects: value,
loading,
error: error?.message,
};
useEffect(() => {
getObjects();
/* eslint-disable react-hooks/exhaustive-deps */
}, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]);
/* eslint-enable react-hooks/exhaustive-deps */
useInterval(() => {
getObjects();
}, intervalMs);
return result;
};