From 0ad476a720f520b267e446f2d64c7cafdd8b5462 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Wed, 30 Nov 2022 13:17:40 -0500 Subject: [PATCH] gracefully surface FetchErrors Signed-off-by: Jamie Klassen --- .../service/KubernetesFanOutHandler.test.ts | 101 ++++++++++++++++++ .../src/service/KubernetesFanOutHandler.ts | 9 ++ plugins/kubernetes-common/api-report.md | 27 +++-- plugins/kubernetes-common/src/types.ts | 11 +- .../src/components/ErrorPanel/ErrorPanel.tsx | 3 +- 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 1af5371e53..fcb8eceb19 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -20,8 +20,14 @@ import { CustomResource, FetchResponseWrapper, ObjectFetchParams, + KubernetesServiceLocator, } from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; const fetchObjectsForService = jest.fn(); const fetchPodMetricsByNamespaces = jest.fn(); @@ -751,6 +757,101 @@ describe('getKubernetesObjectsByEntity', () => { ], }); }); + describe('with a real fetcher', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + it('fetch error short-circuits requests to a single cluster, recovering across the fleet', async () => { + const pods = [{ metadata: { name: 'pod-name' } }]; + const services = [{ metadata: { name: 'service-name' } }]; + worker.use( + rest.get('https://works/api/v1/pods', (_, res, ctx) => + res(ctx.json({ items: pods })), + ), + rest.get('https://works/api/v1/services', (_, res, ctx) => + res(ctx.json({ items: services })), + ), + rest.get('https://fails/api/v1/pods', (_, res) => + res.networkError('socket error'), + ), + rest.get('https://fails/api/v1/services', (_, res, ctx) => + res(ctx.json({ items: services })), + ), + ); + const fleet: jest.Mocked = { + getClustersByEntity: jest.fn().mockResolvedValue({ + clusters: [ + { + name: 'works', + url: 'https://works', + authProvider: 'serviceAccount', + serviceAccountToken: 'token', + skipMetricsLookup: true, + }, + { + name: 'fails', + url: 'https://fails', + authProvider: 'serviceAccount', + serviceAccountToken: 'token', + skipMetricsLookup: true, + }, + ], + }), + }; + const logger = getVoidLogger(); + const sut = new KubernetesFanOutHandler({ + logger, + fetcher: new KubernetesClientBasedFetcher({ logger }), + serviceLocator: fleet, + customResources: [], + objectTypesToFetch: [ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + { + group: '', + apiVersion: 'v1', + plural: 'services', + objectType: 'services', + }, + ], + }); + + const result = await sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + + const expected: ObjectsByEntityResponse = { + items: [ + { + cluster: { name: 'works' }, + resources: [ + { type: 'pods', resources: pods }, + { type: 'services', resources: services }, + ], + podMetrics: [], + errors: [], + }, + { + cluster: { name: 'fails' }, + resources: [], + podMetrics: [], + errors: [ + { + errorType: 'FETCH_ERROR', + message: + 'request to https://fails/api/v1/pods?labelSelector=backstage.io/kubernetes-id=test-component failed, reason: socket error', + }, + ], + }, + ], + }; + expect(result).toStrictEqual(expected); + }); + }); }); describe('getCustomResourcesByEntity', () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 1fa9d23837..bc8881f95b 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -274,6 +274,15 @@ export class KubernetesFanOutHandler { namespace, }) .then(result => this.getMetricsForPods(clusterDetailsItem, result)) + .catch((e): responseWithMetrics => { + return [ + { + errors: [{ errorType: 'FETCH_ERROR', message: e.message }], + responses: [], + }, + [], + ]; + }) .then(r => this.toClusterObjects(clusterDetailsItem, r)); }), ).then(this.toObjectsByEntityResponse); diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 8509fa97cc..fe8aa19966 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -184,14 +184,7 @@ export type KubernetesErrorTypes = | 'UNKNOWN_ERROR'; // @public (undocumented) -export interface KubernetesFetchError { - // (undocumented) - errorType: KubernetesErrorTypes; - // (undocumented) - resourcePath?: string; - // (undocumented) - statusCode?: number; -} +export type KubernetesFetchError = StatusError | RawFetchError; // @public (undocumented) export interface KubernetesRequestAuth { @@ -241,6 +234,14 @@ export interface PodStatusFetchResponse { type: 'podstatus'; } +// @public (undocumented) +export interface RawFetchError { + // (undocumented) + errorType: 'FETCH_ERROR'; + // (undocumented) + message: string; +} + // @public (undocumented) export interface ReplicaSetsFetchResponse { // (undocumented) @@ -265,6 +266,16 @@ export interface StatefulSetsFetchResponse { type: 'statefulsets'; } +// @public (undocumented) +export interface StatusError { + // (undocumented) + errorType: KubernetesErrorTypes; + // (undocumented) + resourcePath?: string; + // (undocumented) + statusCode?: number; +} + // @public (undocumented) export interface WorkloadsByEntityRequest { // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 3feddd7f4c..f723a46d16 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -223,12 +223,21 @@ export interface PodStatusFetchResponse { } /** @public */ -export interface KubernetesFetchError { +export type KubernetesFetchError = StatusError | RawFetchError; + +/** @public */ +export interface StatusError { errorType: KubernetesErrorTypes; statusCode?: number; resourcePath?: string; } +/** @public */ +export interface RawFetchError { + errorType: 'FETCH_ERROR'; + message: string; +} + /** @public */ export type KubernetesErrorTypes = | 'BAD_REQUEST' diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx index fcdeeef393..1e55cd8d18 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx @@ -29,7 +29,8 @@ const clustersWithErrorsToErrorMessage = ( {c.errors.map((e, j) => { return ( - {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`} + {e.errorType !== 'FETCH_ERROR' && + `Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`} ); })}