diff --git a/.changeset/lazy-fireants-check.md b/.changeset/lazy-fireants-check.md new file mode 100644 index 0000000000..234501b07a --- /dev/null +++ b/.changeset/lazy-fireants-check.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Add useCustomResources react hook for fetching Kubernetes Custom Resources diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index b7ec80aee3..3e1b2f0198 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -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) diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 05fcdefc8a..77708f1287 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -64,12 +64,30 @@ class MockKubernetesClient implements KubernetesApi { async getWorkloadsByEntity( _request: WorkloadsByEntityRequest, ): Promise { - throw new Error('Method not implemented.'); + return { + items: [ + { + cluster: { name: 'mock-cluster' }, + resources: this.resources, + podMetrics: [], + errors: [], + }, + ], + }; } async getCustomObjectsByEntity( _request: CustomObjectsByEntityRequest, ): Promise { - throw new Error('Method not implemented.'); + return { + items: [ + { + cluster: { name: 'mock-cluster' }, + resources: this.resources, + podMetrics: [], + errors: [], + }, + ], + }; } async getObjectsByEntity(): Promise { diff --git a/plugins/kubernetes/src/hooks/auth.test.ts b/plugins/kubernetes/src/hooks/auth.test.ts new file mode 100644 index 0000000000..aa2b9afb0d --- /dev/null +++ b/plugins/kubernetes/src/hooks/auth.test.ts @@ -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, + }); + }); +}); diff --git a/plugins/kubernetes/src/hooks/auth.ts b/plugins/kubernetes/src/hooks/auth.ts new file mode 100644 index 0000000000..cce0bc27d6 --- /dev/null +++ b/plugins/kubernetes/src/hooks/auth.ts @@ -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 ?? {}; +}; diff --git a/plugins/kubernetes/src/hooks/index.ts b/plugins/kubernetes/src/hooks/index.ts index 0a5835eee4..c3968a19b2 100644 --- a/plugins/kubernetes/src/hooks/index.ts +++ b/plugins/kubernetes/src/hooks/index.ts @@ -15,6 +15,7 @@ */ export * from './useKubernetesObjects'; +export * from './useCustomResources'; export * from './PodNamesWithErrors'; export * from './PodNamesWithMetrics'; export * from './GroupedResponses'; diff --git a/plugins/kubernetes/src/hooks/types.ts b/plugins/kubernetes/src/hooks/types.ts new file mode 100644 index 0000000000..0796089baf --- /dev/null +++ b/plugins/kubernetes/src/hooks/types.ts @@ -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; +} diff --git a/plugins/kubernetes/src/hooks/useCustomResources.test.ts b/plugins/kubernetes/src/hooks/useCustomResources.test.ts new file mode 100644 index 0000000000..abd8fc392c --- /dev/null +++ b/plugins/kubernetes/src/hooks/useCustomResources.test.ts @@ -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(); + }); + }); +}); diff --git a/plugins/kubernetes/src/hooks/useCustomResources.ts b/plugins/kubernetes/src/hooks/useCustomResources.ts new file mode 100644 index 0000000000..c0ec7d91ea --- /dev/null +++ b/plugins/kubernetes/src/hooks/useCustomResources.ts @@ -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 => { + 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, + }; +}; diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts index 4b66467403..5c4db4db14 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.test.ts @@ -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(); }); }); diff --git a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts index 421ed787da..87d6a0fe8b 100644 --- a/plugins/kubernetes/src/hooks/useKubernetesObjects.ts +++ b/plugins/kubernetes/src/hooks/useKubernetesObjects.ts @@ -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: undefined, - error: undefined, - }); + const getCustomObjects = + useCallback(async (): Promise => { + 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; };