From c010632f880761cc21d36118917c4e0af1fc6b4e Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 2 Dec 2021 23:10:48 +0000 Subject: [PATCH] kubernetes-plugin: integrate with top method from kubernetes client library (#8248) * kubernetes-plugin: integrate with top method from kubernetes client library Signed-off-by: mclarke * fix existing tests Signed-off-by: mclarke * prettier Signed-off-by: mclarke * add fetcher test Signed-off-by: mclarke * update api report Signed-off-by: mclarke * fix fe tests Signed-off-by: mclarke * fe tests Signed-off-by: mclarke * changeset Signed-off-by: mclarke * add skip metrics lookup flag Signed-off-by: mclarke * add skip metrics lookup to gke locator and docs Signed-off-by: mclarke * fix tests Signed-off-by: mclarke * minor change Signed-off-by: mclarke * missed prettier Signed-off-by: mclarke * missed file Signed-off-by: mclarke * more details to changeset Signed-off-by: mclarke --- .changeset/fresh-radios-compete.md | 14 ++ docs/features/kubernetes/configuration.md | 20 +- plugins/kubernetes-backend/api-report.md | 7 + .../examples/dice-roller/metrics-server.yaml | 137 ++++++++++++ plugins/kubernetes-backend/package.json | 2 +- .../ConfigClusterLocator.test.ts | 8 + .../cluster-locator/ConfigClusterLocator.ts | 1 + .../cluster-locator/GkeClusterLocator.test.ts | 5 + .../src/cluster-locator/GkeClusterLocator.ts | 7 +- .../src/cluster-locator/index.test.ts | 2 + .../src/service/KubernetesBuilder.test.ts | 8 + .../service/KubernetesClientProvider.test.ts | 4 +- .../src/service/KubernetesClientProvider.ts | 27 +-- .../service/KubernetesFanOutHandler.test.ts | 210 +++++++++++++++++- .../src/service/KubernetesFanOutHandler.ts | 80 +++++++ .../src/service/KubernetesFetcher.test.ts | 7 + .../src/service/KubernetesFetcher.ts | 34 +-- plugins/kubernetes-backend/src/types/types.ts | 10 + plugins/kubernetes-common/api-report.md | 44 +++- plugins/kubernetes-common/package.json | 2 +- plugins/kubernetes-common/src/types.ts | 24 +- plugins/kubernetes/dev/index.tsx | 1 + plugins/kubernetes/package.json | 2 +- .../src/components/Cluster/Cluster.test.tsx | 1 + .../src/components/Cluster/Cluster.tsx | 69 +++--- .../CronJobsAccordions.test.tsx | 4 +- .../CustomResources/ArgoRollouts/Rollout.tsx | 6 +- .../DeploymentsAccordions.tsx | 23 +- .../components/ErrorPanel/ErrorPanel.test.tsx | 1 + .../IngressesAccordions/IngressDrawer.tsx | 6 +- .../IngressesAccordions.tsx | 8 +- .../JobsAccordions/JobsAccordions.test.tsx | 2 +- .../src/components/KubernetesContent.test.tsx | 3 + .../src/components/Pods/PodsTable.test.tsx | 115 ++++++++-- .../src/components/Pods/PodsTable.tsx | 77 ++++++- .../error-detection/error-detection.test.ts | 4 + .../src/hooks/PodNamesWithMetrics.ts | 21 ++ plugins/kubernetes/src/hooks/test-utils.tsx | 18 +- plugins/kubernetes/src/types/types.ts | 4 +- plugins/kubernetes/src/utils/pod.test.tsx | 53 +++++ plugins/kubernetes/src/utils/pod.tsx | 67 +++++- yarn.lock | 22 +- 42 files changed, 1005 insertions(+), 155 deletions(-) create mode 100644 .changeset/fresh-radios-compete.md create mode 100644 plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml create mode 100644 plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts create mode 100644 plugins/kubernetes/src/utils/pod.test.tsx diff --git a/.changeset/fresh-radios-compete.md b/.changeset/fresh-radios-compete.md new file mode 100644 index 0000000000..221072eb95 --- /dev/null +++ b/.changeset/fresh-radios-compete.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-kubernetes': minor +'@backstage/plugin-kubernetes-backend': minor +'@backstage/plugin-kubernetes-common': minor +--- + +Add pod metrics lookup and display in pod table. + +## Backwards incompatible changes + +If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed, +you will need to set the `skipMetricsLookup` config flag to `false`. + +See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index a7f9b0cd1e..07a12f54a0 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -26,6 +26,7 @@ kubernetes: name: minikube authProvider: 'serviceAccount' skipTLSVerify: false + skipMetricsLookup: true serviceAccountToken: ${K8S_MINIKUBE_TOKEN} dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard dashboardApp: standard @@ -37,6 +38,7 @@ kubernetes: projectId: 'gke-clusters' region: 'europe-west1' skipTLSVerify: true + skipMetricsLookup: true ``` ### `serviceLocatorMethod` @@ -86,8 +88,13 @@ cluster. Valid values are: ##### `clusters.\*.skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `clusters.\*.skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ##### `clusters.\*.serviceAccountToken` (optional) @@ -188,8 +195,13 @@ regions. ##### `skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ### `customResources` (optional) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index e1236e61c5..7d3adac1c4 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -10,6 +10,7 @@ import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; // Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -34,6 +35,7 @@ export interface ClusterDetails { name: string; // (undocumented) serviceAccountToken?: string | undefined; + skipMetricsLookup?: boolean; // (undocumented) skipTLSVerify?: boolean; // (undocumented) @@ -164,6 +166,11 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + // (undocumented) + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } // Warning: (ae-missing-release-tag) "KubernetesObjectsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml new file mode 100644 index 0000000000..673064aaf2 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml @@ -0,0 +1,137 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: system:aggregated-metrics-reader + labels: + rbac.authorization.k8s.io/aggregate-to-view: 'true' + rbac.authorization.k8s.io/aggregate-to-edit: 'true' + rbac.authorization.k8s.io/aggregate-to-admin: 'true' +rules: + - apiGroups: ['metrics.k8s.io'] + resources: ['pods'] + verbs: ['get', 'list', 'watch'] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: metrics-server:system:auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: RoleBinding +metadata: + name: metrics-server-auth-reader + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: apiregistration.k8s.io/v1beta1 +kind: APIService +metadata: + name: v1beta1.metrics.k8s.io +spec: + service: + name: metrics-server + namespace: kube-system + group: metrics.k8s.io + version: v1beta1 + insecureSkipTLSVerify: true + groupPriorityMinimum: 100 + versionPriority: 100 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metrics-server + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metrics-server + namespace: kube-system + labels: + k8s-app: metrics-server +spec: + selector: + matchLabels: + k8s-app: metrics-server + template: + metadata: + name: metrics-server + labels: + k8s-app: metrics-server + spec: + serviceAccountName: metrics-server + volumes: + # mount in tmp so we can safely use from-scratch images and/or read-only containers + - name: tmp-dir + emptyDir: {} + containers: + - name: metrics-server + image: k8s.gcr.io/metrics-server-amd64:v0.3.1 + args: + - --kubelet-insecure-tls + - --kubelet-preferred-address-types=InternalIP + imagePullPolicy: Always + volumeMounts: + - name: tmp-dir + mountPath: /tmp + +--- +apiVersion: v1 +kind: Service +metadata: + name: metrics-server + namespace: kube-system + labels: + kubernetes.io/name: 'Metrics-server' +spec: + selector: + k8s-app: metrics-server + ports: + - port: 443 + protocol: TCP + targetPort: 443 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:metrics-server +rules: + - apiGroups: + - '' + resources: + - pods + - nodes + - nodes/stats + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:metrics-server +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:metrics-server +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 02fb3586e0..c2f4eae4ba 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -38,7 +38,7 @@ "@backstage/errors": "^0.1.5", "@backstage/plugin-kubernetes-common": "^0.1.7", "@google-cloud/container": "^2.2.0", - "@kubernetes/client-node": "^0.15.0", + "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index 62346ac6c3..f5d6b56893 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -52,6 +52,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -67,6 +68,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, dashboardUrl: 'https://k8s.foo.com', }, { @@ -74,6 +76,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, }, ], }); @@ -90,6 +93,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, caData: undefined, }, { @@ -98,6 +102,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); @@ -144,6 +149,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'aws', skipTLSVerify: false, + skipMetricsLookup: false, caData: undefined, }, { @@ -154,6 +160,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, { @@ -164,6 +171,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index bb9dbfe13f..36b5f58f2c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -35,6 +35,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false, caData: c.getOptionalString('caData'), authProvider: authProvider, }; diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 545f839907..bd342f566b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -93,6 +93,7 @@ describe('GkeClusterLocator', () => { type: 'gke', projectId: 'some-project', region: 'some-region', + skipMetricsLookup: true, }); const sut = GkeClusterLocator.fromConfigWithClient(config, { @@ -107,6 +108,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: true, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -143,6 +145,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -184,12 +187,14 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, { authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 3aa4a3e941..d7f5050399 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -23,6 +23,7 @@ type GkeClusterLocatorOptions = { projectId: string; region?: string; skipTLSVerify?: boolean; + skipMetricsLookup?: boolean; }; export class GkeClusterLocator implements KubernetesClustersSupplier { @@ -39,6 +40,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { projectId: config.getString('projectId'), region: config.getOptionalString('region') ?? '-', skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: + config.getOptionalBoolean('skipMetricsLookup') ?? false, }; return new GkeClusterLocator(options, client); } @@ -52,7 +55,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // TODO pass caData into the object async getClusters(): Promise { - const { projectId, region, skipTLSVerify } = this.options; + const { projectId, region, skipTLSVerify, skipMetricsLookup } = + this.options; const request = { parent: `projects/${projectId}/locations/${region}`, }; @@ -65,6 +69,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { url: `https://${r.endpoint ?? ''}`, authProvider: 'google', skipTLSVerify, + skipMetricsLookup, })); } catch (e) { throw new ForwardedError( diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index dde4dc1d93..2bc2c719a0 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -53,6 +53,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -61,6 +62,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index de0dcdf373..37dad8c3e4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -29,6 +29,7 @@ import { } from '../types/types'; import { KubernetesBuilder } from './KubernetesBuilder'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node'; describe('KubernetesBuilder', () => { let app: express.Express; @@ -194,6 +195,7 @@ describe('KubernetesBuilder', () => { name: someCluster.name, }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -211,6 +213,12 @@ describe('KubernetesBuilder', () => { }; const fetcher: KubernetesFetcher = { + fetchPodMetricsByNamespace( + _clusterDetails: ClusterDetails, + _namespace: string, + ): Promise { + return Promise.resolve([]); + }, fetchObjectsForService( _params: ObjectFetchParams, ): Promise { diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index 96bd1fe7c1..e813a63f0d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -47,14 +47,14 @@ describe('KubernetesClientProvider', () => { expect(mockGetKubeConfig.mock.calls.length).toBe(1); }); - it('can get apps client by cluster details', async () => { + it('can get custom objects client by cluster details', async () => { const sut = new KubernetesClientProvider(); const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); sut.getKubeConfig = mockGetKubeConfig; - const result = sut.getAppsClientByClusterDetails({ + const result = sut.getCustomObjectsClient({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts index 4a91118c7d..031e40547c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -15,12 +15,9 @@ */ import { - AppsV1Api, - BatchV1beta1Api, - AutoscalingV1Api, CoreV1Api, KubeConfig, - NetworkingV1beta1Api, + Metrics, CustomObjectsApi, } from '@kubernetes/client-node'; import { ClusterDetails } from '../types/types'; @@ -63,28 +60,10 @@ export class KubernetesClientProvider { return kc.makeApiClient(CoreV1Api); } - getAppsClientByClusterDetails(clusterDetails: ClusterDetails) { + getMetricsClient(clusterDetails: ClusterDetails) { const kc = this.getKubeConfig(clusterDetails); - return kc.makeApiClient(AppsV1Api); - } - - getAutoscalingClientByClusterDetails(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(AutoscalingV1Api); - } - - getBatchClientByClusterDetails(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(BatchV1beta1Api); - } - - getNetworkingBeta1Client(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(NetworkingV1beta1Api); + return new Metrics(kc); } getCustomObjectsClient(clusterDetails: ClusterDetails) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 7973448467..da8f703ba0 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -15,13 +15,30 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { ObjectFetchParams } from '../types/types'; +import { ClusterDetails, ObjectFetchParams } from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; const fetchObjectsForService = jest.fn(); +const fetchPodMetricsByNamespace = jest.fn(); const getClustersByServiceId = jest.fn(); +const POD_METRICS_FIXTURE = { + containers: [], + cpu: { + currentUsage: 100, + limitTotal: 102, + requestTotal: 101, + }, + memory: { + currentUsage: '1000', + limitTotal: '1002', + requestTotal: '1001', + }, + pod: {}, +}; + const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((params: ObjectFetchParams) => Promise.resolve( @@ -33,6 +50,34 @@ const mockFetch = (mock: jest.Mock) => { ); }; +const mockMetrics = (mock: jest.Mock) => { + mock.mockImplementation((clusterDetails: ClusterDetails, namespace: string) => + Promise.resolve(generatePodStatus(clusterDetails.name, namespace)), + ); +}; + +function generatePodStatus( + _clusterName: string, + _namespace: string, +): PodStatus[] { + return [ + { + Pod: {}, + CPU: { + CurrentUsage: 100, + RequestTotal: 101, + LimitTotal: 102, + }, + Memory: { + CurrentUsage: BigInt('1000'), + RequestTotal: BigInt('1001'), + LimitTotal: BigInt('1002'), + }, + Containers: [], + }, + ] as any; +} + function generateMockResourcesAndErrors( serviceId: string, clusterName: string, @@ -84,6 +129,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-pods-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -94,6 +140,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-configmaps-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -104,6 +151,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-services-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -128,11 +176,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -161,6 +211,11 @@ describe('handleGetKubernetesObjectsForService', () => { expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe( + 'ns-test-component-test-cluster', + ); + expect(result).toStrictEqual({ items: [ { @@ -168,12 +223,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -184,6 +241,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -194,6 +252,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -205,6 +264,124 @@ describe('handleGetKubernetesObjectsForService', () => { }); }); + it('dont call top for the same namespace twice', async () => { + getClustersByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + ]), + ); + + fetchObjectsForService.mockImplementation((_: ObjectFetchParams) => + Promise.resolve({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: `pod1`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod2`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod3`, + namespace: `ns-b`, + }, + }, + ], + }, + ], + }), + ); + + mockMetrics(fetchPodMetricsByNamespace); + + const sut = new KubernetesFanOutHandler({ + logger: getVoidLogger(), + fetcher: { + fetchObjectsForService, + fetchPodMetricsByNamespace, + }, + serviceLocator: { + getClustersByServiceId, + }, + customResources: [], + }); + + const result = await sut.getKubernetesObjectsByEntity({ + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, + }, + }); + + expect(getClustersByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(2); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe('ns-a'); + expect(fetchPodMetricsByNamespace.mock.calls[1][1]).toBe('ns-b'); + + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + podMetrics: [POD_METRICS_FIXTURE, POD_METRICS_FIXTURE], + resources: [ + { + resources: [ + { + metadata: { + name: 'pod1', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod2', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod3', + namespace: 'ns-b', + }, + }, + ], + type: 'pods', + }, + ], + }, + ], + }); + }); + it('retrieve objects for two clusters', async () => { getClustersByServiceId.mockImplementation(() => Promise.resolve([ @@ -221,11 +398,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -265,12 +444,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -281,6 +462,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -291,6 +473,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -303,12 +486,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -319,6 +504,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -329,6 +515,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -358,11 +545,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -401,12 +590,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -417,6 +608,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -427,6 +619,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -439,12 +632,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -455,6 +650,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -465,6 +661,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -498,11 +695,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -541,12 +740,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -557,6 +758,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -567,6 +769,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -579,12 +782,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -595,6 +800,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -605,6 +811,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -617,6 +824,7 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'error-cluster', }, errors: ['some random cluster error'], + podMetrics: [], resources: [ { type: 'pods', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 41d75a9835..a25c220b80 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -27,9 +27,19 @@ import { import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; import { + ClientContainerStatus, + ClientCurrentResourceUsage, + ClientPodStatus, ClusterObjects, + FetchResponse, ObjectsByEntityResponse, + PodFetchResponse, } from '@backstage/plugin-kubernetes-common'; +import { + ContainerStatus, + CurrentResourceUsage, + PodStatus, +} from '@kubernetes/client-node'; export const DEFAULT_OBJECTS: ObjectToFetch[] = [ { @@ -93,6 +103,50 @@ export interface KubernetesFanOutHandlerOptions export interface KubernetesRequestBody extends ObjectsByEntityRequest {} +const isPodFetchResponse = (fr: FetchResponse): fr is PodFetchResponse => + fr.type === 'pods'; +const isString = (str: string | undefined): str is string => str !== undefined; + +const numberOrBigIntToNumberOrString = ( + value: number | BigInt, +): number | string => { + // @ts-ignore + return typeof value === 'bigint' ? value.toString() : value; +}; + +const toClientSafeResource = ( + current: CurrentResourceUsage, +): ClientCurrentResourceUsage => { + return { + currentUsage: numberOrBigIntToNumberOrString(current.CurrentUsage), + requestTotal: numberOrBigIntToNumberOrString(current.RequestTotal), + limitTotal: numberOrBigIntToNumberOrString(current.LimitTotal), + }; +}; + +const toClientSafeContainer = ( + container: ContainerStatus, +): ClientContainerStatus => { + return { + container: container.Container, + cpuUsage: toClientSafeResource(container.CPUUsage), + memoryUsage: toClientSafeResource(container.MemoryUsage), + }; +}; + +const toClientSafePodMetrics = ( + podMetrics: PodStatus[][], +): ClientPodStatus[] => { + return podMetrics.flat().map((pd: PodStatus): ClientPodStatus => { + return { + pod: pd.Pod, + memory: toClientSafeResource(pd.Memory), + cpu: toClientSafeResource(pd.CPU), + containers: pd.Containers.map(toClientSafeContainer), + }; + }); +}; + export class KubernetesFanOutHandler { private readonly logger: Logger; private readonly fetcher: KubernetesFetcher; @@ -162,10 +216,36 @@ export class KubernetesFanOutHandler { customResources: this.customResources, }) .then(result => { + if (clusterDetailsItem.skipMetricsLookup) { + return Promise.all([ + Promise.resolve(result), + Promise.resolve([]), + ]); + } + // TODO refactor, extract as method + const namespaces: Set = new Set( + result.responses + .filter(isPodFetchResponse) + .flatMap(r => r.resources) + .map(p => p.metadata?.namespace) + .filter(isString), + ); + + const podMetrics = Array.from(namespaces).map(ns => + this.fetcher.fetchPodMetricsByNamespace(clusterDetailsItem, ns), + ); + + return Promise.all([ + Promise.resolve(result), + Promise.all(podMetrics), + ]); + }) + .then(([result, metrics]) => { const objects: ClusterObjects = { cluster: { name: clusterDetailsItem.name, }, + podMetrics: toClientSafePodMetrics(metrics), resources: result.responses, errors: result.errors, }; diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index e86901ef92..e1f701b75c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -106,6 +106,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -116,6 +117,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -197,6 +199,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -207,6 +210,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -316,6 +320,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -326,6 +331,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -336,6 +342,7 @@ describe('KubernetesFetcher', () => { 'v2', 'things', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 685df85c33..a2c3b9a6a8 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -14,13 +14,7 @@ * limitations under the License. */ -import { - AppsV1Api, - AutoscalingV1Api, - BatchV1beta1Api, - CoreV1Api, - NetworkingV1beta1Api, -} from '@kubernetes/client-node'; +import { CoreV1Api, topPods } from '@kubernetes/client-node'; import lodash, { Dictionary } from 'lodash'; import { Logger } from 'winston'; import { @@ -37,13 +31,10 @@ import { KubernetesErrorTypes, } from '@backstage/plugin-kubernetes-common'; import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface Clients { core: CoreV1Api; - apps: AppsV1Api; - autoscaling: AutoscalingV1Api; - batch: BatchV1beta1Api; - networkingBeta1: NetworkingV1beta1Api; } export interface KubernetesClientBasedFetcherOptions { @@ -112,10 +103,26 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); } + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise { + const metricsClient = + this.kubernetesClientProvider.getMetricsClient(clusterDetails); + const coreApi = + this.kubernetesClientProvider.getCoreClientByClusterDetails( + clusterDetails, + ); + + return topPods(coreApi, metricsClient, namespace); + } + private captureKubernetesErrorsRethrowOthers(e: any): KubernetesFetchError { if (e.response && e.response.statusCode) { - this.logger.info( - `statusCode=${e.response.statusCode} for resource ${e.response.request.uri.pathname}`, + this.logger.warn( + `statusCode=${e.response.statusCode} for resource ${ + e.response.request.uri.pathname + } body=[${JSON.stringify(e.response.body)}]`, ); return { errorType: statusCodeToErrorType(e.response.statusCode), @@ -145,6 +152,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { resource.apiVersion, resource.plural, '', + false, '', '', labelSelector, diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index b8aa482e47..a0ec0832c1 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -21,6 +21,7 @@ import type { KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface ObjectFetchParams { serviceId: string; @@ -40,6 +41,10 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } export interface FetchResponseWrapper { @@ -93,6 +98,11 @@ export interface ClusterDetails { authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + /** + * Whether to skip the lookup to the metrics server to retrieve pod resource usage. + * It is not guaranteed that the Kubernetes distro has the metrics server installed. + */ + skipMetricsLookup?: boolean; caData?: string | undefined; /** * Specifies the link to the Kubernetes dashboard managing this cluster. diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 7db05a7513..f3500aa271 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -4,11 +4,11 @@ ```ts import { Entity } from '@backstage/catalog-model'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; import { V1ConfigMap } from '@kubernetes/client-node'; import { V1CronJob } from '@kubernetes/client-node'; import { V1Deployment } from '@kubernetes/client-node'; import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { V1Job } from '@kubernetes/client-node'; import { V1Pod } from '@kubernetes/client-node'; import { V1ReplicaSet } from '@kubernetes/client-node'; @@ -19,6 +19,44 @@ import { V1Service } from '@kubernetes/client-node'; // @public (undocumented) export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +// Warning: (ae-missing-release-tag) "ClientContainerStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientContainerStatus { + // (undocumented) + container: string; + // (undocumented) + cpuUsage: ClientCurrentResourceUsage; + // (undocumented) + memoryUsage: ClientCurrentResourceUsage; +} + +// Warning: (ae-missing-release-tag) "ClientCurrentResourceUsage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientCurrentResourceUsage { + // (undocumented) + currentUsage: number | string; + // (undocumented) + limitTotal: number | string; + // (undocumented) + requestTotal: number | string; +} + +// Warning: (ae-missing-release-tag) "ClientPodStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientPodStatus { + // (undocumented) + containers: ClientContainerStatus[]; + // (undocumented) + cpu: ClientCurrentResourceUsage; + // (undocumented) + memory: ClientCurrentResourceUsage; + // (undocumented) + pod: V1Pod; +} + // Warning: (ae-missing-release-tag) "ClusterAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -37,6 +75,8 @@ export interface ClusterObjects { // (undocumented) errors: KubernetesFetchError[]; // (undocumented) + podMetrics: ClientPodStatus[]; + // (undocumented) resources: FetchResponse[]; } @@ -110,7 +150,7 @@ export interface HorizontalPodAutoscalersFetchResponse { // @public (undocumented) export interface IngressesFetchResponse { // (undocumented) - resources: Array; + resources: Array; // (undocumented) type: 'ingresses'; } diff --git a/plugins/kubernetes-common/package.json b/plugins/kubernetes-common/package.json index 0a6640332e..b3de378021 100644 --- a/plugins/kubernetes-common/package.json +++ b/plugins/kubernetes-common/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.9.7", - "@kubernetes/client-node": "^0.15.0" + "@kubernetes/client-node": "^0.16.0" }, "devDependencies": { "@backstage/cli": "^0.10.0" diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 8f4daa111b..0c5af00a97 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -15,11 +15,11 @@ */ import { - ExtensionsV1beta1Ingress, V1ConfigMap, V1CronJob, V1Deployment, V1HorizontalPodAutoscaler, + V1Ingress, V1Job, V1Pod, V1ReplicaSet, @@ -69,6 +69,7 @@ export interface ClusterAttributes { export interface ClusterObjects { cluster: ClusterAttributes; resources: FetchResponse[]; + podMetrics: ClientPodStatus[]; errors: KubernetesFetchError[]; } @@ -132,7 +133,7 @@ export interface CronJobsFetchResponse { export interface IngressesFetchResponse { type: 'ingresses'; - resources: Array; + resources: Array; } export interface CustomResourceFetchResponse { @@ -151,3 +152,22 @@ export type KubernetesErrorTypes = | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; + +export interface ClientCurrentResourceUsage { + currentUsage: number | string; + requestTotal: number | string; + limitTotal: number | string; +} + +export interface ClientContainerStatus { + container: string; + cpuUsage: ClientCurrentResourceUsage; + memoryUsage: ClientCurrentResourceUsage; +} + +export interface ClientPodStatus { + pod: V1Pod; + cpu: ClientCurrentResourceUsage; + memory: ClientCurrentResourceUsage; + containers: ClientContainerStatus[]; +} diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index c7a99e7334..068f26b7f5 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -66,6 +66,7 @@ class MockKubernetesClient implements KubernetesApi { { cluster: { name: 'mock-cluster' }, resources: this.resources, + podMetrics: [], errors: [], }, ], diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 4e594c69b3..5e7a5bdf8d 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -37,8 +37,8 @@ "@backstage/core-plugin-api": "^0.2.2", "@backstage/plugin-catalog-react": "^0.6.4", "@backstage/plugin-kubernetes-common": "^0.1.7", + "@kubernetes/client-node": "^0.16.0", "@backstage/theme": "^0.2.14", - "@kubernetes/client-node": "^0.15.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx index ddadd95272..620af05e69 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx @@ -46,6 +46,7 @@ describe('Cluster', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, podsWithErrors: new Set(), diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.tsx index 416d6e23e3..b7a819cdbf 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.tsx @@ -23,7 +23,10 @@ import { Grid, Typography, } from '@material-ui/core'; -import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; +import { + ClientPodStatus, + ClusterObjects, +} from '@backstage/plugin-kubernetes-common'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { DeploymentsAccordions } from '../DeploymentsAccordions'; import { groupResponses } from '../../utils/response'; @@ -38,6 +41,7 @@ import { } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; type ClusterSummaryProps = { clusterName: string; @@ -108,39 +112,50 @@ type ClusterProps = { export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); + const podNameToMetrics = clusterObjects.podMetrics + .flat() + .reduce((accum, next) => { + const name = next.pod.metadata?.name; + if (name !== undefined) { + accum.set(name, next); + } + return accum; + }, new Map()); return ( - - - }> - - - - - - - - - - - - - - - + + + + }> + + + + + + + + + + + + + + + + - - - - + + + + ); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx index 2434631fd0..9d2df328d2 100644 --- a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx @@ -23,7 +23,7 @@ import { kubernetesProviders } from '../../hooks/test-utils'; describe('CronJobsAccordions', () => { it('should render 1 active cronjobs', async () => { - const wrapper = kubernetesProviders(oneCronJobsFixture, []); + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); const { getByText } = render( wrapper(wrapInTestApp()), @@ -36,7 +36,7 @@ describe('CronJobsAccordions', () => { }); it('should render 1 suspended cronjobs', async () => { - const wrapper = kubernetesProviders(twoCronJobsFixture, []); + const wrapper = kubernetesProviders(twoCronJobsFixture, new Set()); const { getByText } = render( wrapper(wrapInTestApp()), diff --git a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx index e14cd9093a..52734e39a8 100644 --- a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx +++ b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx @@ -41,6 +41,7 @@ import { getOwnedPodsThroughReplicaSets, } from '../../../utils/owner'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../../Pods/PodsTable'; type RolloutAccordionsProps = { rollouts: any[]; @@ -237,7 +238,10 @@ const RolloutAccordion = ({ />
- +
diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx index 2fa140b5f7..58ab2fc2ba 100644 --- a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -36,12 +36,12 @@ import { getOwnedPodsThroughReplicaSets, getMatchingHpa, } from '../../utils/owner'; -import { containersReady, totalRestarts } from '../../utils/pod'; import { GroupedResponsesContext, PodNamesWithErrorsContext, } from '../../hooks'; -import { StatusError, StatusOK, TableColumn } from '@backstage/core-components'; +import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../Pods/PodsTable'; type DeploymentsAccordionsProps = { children?: React.ReactNode; @@ -62,20 +62,6 @@ type DeploymentSummaryProps = { children?: React.ReactNode; }; -const deploymentPodColumns: TableColumn[] = [ - { - title: 'containers ready', - align: 'center', - render: containersReady, - }, - { - title: 'total restarts', - align: 'center', - render: totalRestarts, - type: 'numeric', - }, -]; - const DeploymentSummary = ({ deployment, numberOfCurrentPods, @@ -176,7 +162,10 @@ const DeploymentAccordion = ({ /> - + ); diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx index 02154c4825..832d6eed91 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx @@ -51,6 +51,7 @@ describe('ErrorPanel', () => { name: 'THIS_CLUSTER', }, resources: [], + podMetrics: [], errors: [ { errorType: 'SYSTEM_ERROR', diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx index 0018c4b070..dc88987d53 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; import { Typography, Grid } from '@material-ui/core'; @@ -23,7 +23,7 @@ export const IngressDrawer = ({ ingress, expanded, }: { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; expanded?: boolean; }) => { return ( @@ -31,7 +31,7 @@ export const IngressDrawer = ({ object={ingress} expanded={expanded} kind="Ingress" - renderObject={(ingressObject: ExtensionsV1beta1Ingress) => { + renderObject={(ingressObject: V1Ingress) => { return ingressObject.spec || {}; }} > diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx index c1d94fef7b..f6d6e4bf68 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx @@ -23,7 +23,7 @@ import { Grid, } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { IngressDrawer } from './IngressDrawer'; import { GroupedResponsesContext } from '../../hooks'; import { StructuredMetadataTable } from '@backstage/core-components'; @@ -31,11 +31,11 @@ import { StructuredMetadataTable } from '@backstage/core-components'; type IngressesAccordionsProps = {}; type IngressAccordionProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; type IngressSummaryProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressSummary = ({ ingress }: IngressSummaryProps) => { @@ -58,7 +58,7 @@ const IngressSummary = ({ ingress }: IngressSummaryProps) => { }; type IngressCardProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressCard = ({ ingress }: IngressCardProps) => { diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx index 093d616319..ea16e933c6 100644 --- a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx @@ -23,7 +23,7 @@ import { V1Job, ObjectSerializer } from '@kubernetes/client-node'; describe('JobsAccordions', () => { it('should render 2 jobs', async () => { - const wrapper = kubernetesProviders(oneCronJobsFixture, []); + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); const jobs: V1Job[] = oneCronJobsFixture.jobs.map( job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job, diff --git a/plugins/kubernetes/src/components/KubernetesContent.test.tsx b/plugins/kubernetes/src/components/KubernetesContent.test.tsx index db8cd06782..231eb3b37a 100644 --- a/plugins/kubernetes/src/components/KubernetesContent.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent.test.tsx @@ -73,6 +73,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], @@ -121,6 +122,7 @@ describe('KubernetesContent', () => { resources: twoDeployments.pods, }, ], + podMetrics: [], errors: [], }, { @@ -139,6 +141,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx index 868b74bbc9..1201ad106d 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx @@ -18,25 +18,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import * as pod from './__fixtures__/pod.json'; import * as crashingPod from './__fixtures__/crashing-pod.json'; -import { TableColumn } from '@backstage/core-components'; import { wrapInTestApp } from '@backstage/test-utils'; -import { V1Pod } from '@kubernetes/client-node'; -import { PodsTable } from './PodsTable'; -import { containersReady, totalRestarts } from '../../utils/pod'; - -const extraColumns: TableColumn[] = [ - { - title: 'containers ready', - align: 'center', - render: containersReady, - }, - { - title: 'total restarts', - align: 'center', - render: totalRestarts, - type: 'numeric', - }, -]; +import { PodsTable, READY_COLUMNS, RESOURCE_COLUMNS } from './PodsTable'; +import { kubernetesProviders } from '../../hooks/test-utils'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; describe('PodsTable', () => { it('should render pod', async () => { @@ -58,7 +43,7 @@ describe('PodsTable', () => { it('should render pod with extra columns', async () => { const { getByText } = render( wrapInTestApp( - , + , ), ); @@ -76,11 +61,101 @@ describe('PodsTable', () => { expect(getByText('0')).toBeInTheDocument(); expect(getByText('OK')).toBeInTheDocument(); }); + it('should render pod, with metrics context', async () => { + const podNameToClientPodStatus = new Map(); + podNameToClientPodStatus.set('dice-roller-6c8646bfd-2m5hv', { + memory: { + currentUsage: '1069056', + requestTotal: '67108864', + limitTotal: '134217728', + }, + cpu: { + currentUsage: 0.4966115, + requestTotal: 0.05, + limitTotal: 0.05, + }, + } as any); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); + const { getByText } = render( + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getByText('requests: 99%')).toBeInTheDocument(); + expect(getByText('limits: 99%')).toBeInTheDocument(); + expect(getByText('requests: 1%')).toBeInTheDocument(); + expect(getByText('limits: 0%')).toBeInTheDocument(); + }); + it('should render placehoplder when empty metrics context', async () => { + const podNameToClientPodStatus = new Map(); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); + const { getByText, getAllByText } = render( + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getAllByText('unknown')).toHaveLength(2); + }); it('should render crashing pod with extra columns', async () => { const { getByText, getAllByText } = render( wrapInTestApp( - , + , ), ); diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx index 8ce5f813b3..1d2369e322 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -14,11 +14,28 @@ * limitations under the License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { V1Pod } from '@kubernetes/client-node'; import { PodDrawer } from './PodDrawer'; -import { containerStatuses } from '../../utils/pod'; +import { + containersReady, + containerStatuses, + podStatusToCpuUtil, + podStatusToMemoryUtil, + totalRestarts, +} from '../../utils/pod'; import { Table, TableColumn } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; + +export const READY_COLUMNS: PodColumns = 'READY'; +export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE'; +export type PodColumns = 'READY' | 'RESOURCE'; + +type PodsTablesProps = { + pods: V1Pod[]; + extraColumns?: PodColumns[]; + children?: React.ReactNode; +}; const DEFAULT_COLUMNS: TableColumn[] = [ { @@ -36,13 +53,57 @@ const DEFAULT_COLUMNS: TableColumn[] = [ }, ]; -type PodsTablesProps = { - pods: V1Pod[]; - extraColumns?: TableColumn[]; - children?: React.ReactNode; -}; +const READY: TableColumn[] = [ + { + title: 'containers ready', + align: 'center', + render: containersReady, + }, + { + title: 'total restarts', + align: 'center', + render: totalRestarts, + type: 'numeric', + }, +]; export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { + const podNamesWithMetrics = useContext(PodNamesWithMetricsContext); + const columns: TableColumn[] = [...DEFAULT_COLUMNS]; + + if (extraColumns.includes(READY_COLUMNS)) { + columns.push(...READY); + } + if (extraColumns.includes(RESOURCE_COLUMNS)) { + const resourceColumns: TableColumn[] = [ + { + title: 'CPU usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToCpuUtil(metrics); + }, + }, + { + title: 'Memory usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToMemoryUtil(metrics); + }, + }, + ]; + columns.push(...resourceColumns); + } + const tableStyle = { minWidth: '0', width: '100%', @@ -53,7 +114,7 @@ export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { ); diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts index 2e982f25f7..dcac7db907 100644 --- a/plugins/kubernetes/src/error-detection/error-detection.test.ts +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -40,6 +40,7 @@ const oneItem = (value: FetchResponse): ObjectsByEntityResponse => { { cluster: { name: CLUSTER_NAME }, errors: [], + podMetrics: [], resources: [value], }, ], @@ -74,6 +75,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-a' }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -84,6 +86,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-b' }, errors: [], + podMetrics: [], resources: [ { type: 'horizontalpodautoscalers', @@ -94,6 +97,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-c' }, errors: [], + podMetrics: [], resources: [ { type: 'deployments', diff --git a/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts new file mode 100644 index 0000000000..66a833ad88 --- /dev/null +++ b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts @@ -0,0 +1,21 @@ +/* + * 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 React from 'react'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; + +export const PodNamesWithMetricsContext = React.createContext< + Map +>(new Map()); diff --git a/plugins/kubernetes/src/hooks/test-utils.tsx b/plugins/kubernetes/src/hooks/test-utils.tsx index c5b7a8f5d9..a7a6f42512 100644 --- a/plugins/kubernetes/src/hooks/test-utils.tsx +++ b/plugins/kubernetes/src/hooks/test-utils.tsx @@ -17,17 +17,25 @@ import React from 'react'; import { GroupedResponsesContext } from './GroupedResponses'; import { PodNamesWithErrorsContext } from './PodNamesWithErrors'; +import { PodNamesWithMetricsContext } from './PodNamesWithMetrics'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const kubernetesProviders = ( - groupedResponses: any, - podsWithErrors: any, + groupedResponses: any = undefined, + podsWithErrors: Set = new Set(), + podNameToMetrics: Map = new Map< + string, + ClientPodStatus + >(), ) => { return (node: React.ReactNode) => ( <> - - {node} - + + + {node} + + ); diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index d6df99cb95..d6ef8fa784 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -21,7 +21,7 @@ import { V1HorizontalPodAutoscaler, V1Service, V1ConfigMap, - ExtensionsV1beta1Ingress, + V1Ingress, V1Job, V1CronJob, } from '@kubernetes/client-node'; @@ -36,7 +36,7 @@ export interface DeploymentResources { export interface GroupedResponses extends DeploymentResources { services: V1Service[]; configMaps: V1ConfigMap[]; - ingresses: ExtensionsV1beta1Ingress[]; + ingresses: V1Ingress[]; jobs: V1Job[]; cronJobs: V1CronJob[]; customResources: any[]; diff --git a/plugins/kubernetes/src/utils/pod.test.tsx b/plugins/kubernetes/src/utils/pod.test.tsx new file mode 100644 index 0000000000..5822a16b86 --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 React from 'react'; +import { currentToDeclaredResourceToPerc, podStatusToCpuUtil } from './pod'; +import { SubvalueCell } from '@backstage/core-components'; + +describe('pod', () => { + describe('currentToDeclaredResourceToPerc', () => { + it('10%', () => { + const tests: (number | string)[][] = [ + [10, 100], + [10, '100'], + ['10', 100], + ['10', '100'], + ]; + tests.forEach(([a, b]) => { + const result = currentToDeclaredResourceToPerc(a, b); + expect(result).toBe('10%'); + }); + }); + }); + describe('podStatusToCpuUtil', () => { + it('does use correct units', () => { + const result = podStatusToCpuUtil({ + cpu: { + // ~50m + currentUsage: 0.4966115, + // 50m + requestTotal: 0.05, + // 100m + limitTotal: 0.1, + }, + } as any); + expect(result).toStrictEqual( + , + ); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx index 8be2d839c3..bfb40750cd 100644 --- a/plugins/kubernetes/src/utils/pod.tsx +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -14,16 +14,20 @@ * limitations under the License. */ -import { V1Pod, V1PodCondition } from '@kubernetes/client-node'; +import { + V1Pod, + V1PodCondition, + V1DeploymentCondition, +} from '@kubernetes/client-node'; import React, { Fragment, ReactNode } from 'react'; import { Chip } from '@material-ui/core'; -import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition'; import { StatusAborted, StatusError, StatusOK, SubvalueCell, } from '@backstage/core-components'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const imageChips = (pod: V1Pod): ReactNode => { const containerStatuses = pod.status?.containerStatuses ?? []; @@ -102,3 +106,62 @@ export const renderCondition = ( } return [condition.type, ]; }; + +// visible for testing +export const currentToDeclaredResourceToPerc = ( + current: number | string, + resource: number | string, +): string => { + if (typeof current === 'number' && typeof resource === 'number') { + return `${Math.round((current / resource) * 100)}%`; + } + + const numerator: bigint = BigInt(current); + const denominator: bigint = BigInt(resource); + + return `${(numerator * BigInt(100)) / denominator}%`; +}; + +export const podStatusToCpuUtil = (podStatus: ClientPodStatus): ReactNode => { + const cpuUtil = podStatus.cpu; + + let currentUsage: number | string = cpuUtil.currentUsage; + + // current usage number for CPU is a different unit than request/limit total + // this might be a bug in the k8s library + if (typeof cpuUtil.currentUsage === 'number') { + currentUsage = cpuUtil.currentUsage / 10; + } + + return ( + + ); +}; + +export const podStatusToMemoryUtil = ( + podStatus: ClientPodStatus, +): ReactNode => { + const memUtil = podStatus.memory; + + return ( + + ); +}; diff --git a/yarn.lock b/yarn.lock index 2b62b4b1d5..eed87bb021 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3676,12 +3676,12 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@kubernetes/client-node@^0.15.0": - version "0.15.0" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.15.0.tgz#aa5cfcfa9ba3055fe0b510c430d19bbda715d8e7" - integrity sha512-AnEcsWWadl5IWOzzvO/gWpTnJb1d1CzA/rbV/qK1c0fD1SOxTDPj6jFllyQ9icGDfCgNw3TafZftmuepm6z9JA== +"@kubernetes/client-node@^0.16.0": + version "0.16.1" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.16.1.tgz#c78ef667579777c1a532983922807e228dbc9b90" + integrity sha512-/Ah+3gFSjXFeqDMGGTyYBKug44Eu2D2qowKLdiZqxCkHdSNgy+CNk6FU1Vy80WrTvGkF/CZr4az6O5AopAiJEw== dependencies: - "@types/js-yaml" "^3.12.1" + "@types/js-yaml" "^4.0.1" "@types/node" "^10.12.0" "@types/request" "^2.47.1" "@types/stream-buffers" "^3.0.3" @@ -3691,7 +3691,7 @@ byline "^5.0.0" execa "5.0.0" isomorphic-ws "^4.0.1" - js-yaml "^3.13.1" + js-yaml "^4.1.0" jsonpath-plus "^0.19.0" openid-client "^4.1.1" request "^2.88.0" @@ -7397,16 +7397,16 @@ resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9" integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ== -"@types/js-yaml@^3.12.1": - version "3.12.5" - resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb" - integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww== - "@types/js-yaml@^4.0.0": version "4.0.1" resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.1.tgz#5544730b65a480b18ace6b6ce914e519cec2d43b" integrity sha512-xdOvNmXmrZqqPy3kuCQ+fz6wA0xU5pji9cd1nDrflWaAWtYLLGk5ykW0H6yg5TVyehHP1pfmuuSaZkhP+kspVA== +"@types/js-yaml@^4.0.1": + version "4.0.5" + resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" + integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== + "@types/jscodeshift@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@types/jscodeshift/-/jscodeshift-0.11.0.tgz#7224cf1a4d0383b4fb2694ffed52f57b45c3325b"