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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Add useCustomResources react hook for fetching Kubernetes Custom Resources
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {};
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export * from './useKubernetesObjects';
|
||||
export * from './useCustomResources';
|
||||
export * from './PodNamesWithErrors';
|
||||
export * from './PodNamesWithMetrics';
|
||||
export * from './GroupedResponses';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user