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 <mclarke@spotify.com>

* fix existing tests

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

* prettier

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

* add fetcher test

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

* update api report

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

* fix fe tests

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

* fe tests

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

* changeset

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

* add skip metrics lookup flag

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

* add skip metrics lookup to gke locator and docs

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

* fix tests

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

* minor change

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

* missed prettier

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

* missed file

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

* more details to changeset

Signed-off-by: mclarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2021-12-02 23:10:48 +00:00
committed by GitHub
parent a6639021a6
commit c010632f88
42 changed files with 1005 additions and 155 deletions
+14
View File
@@ -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.
+16 -4
View File
@@ -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)
+7
View File
@@ -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<FetchResponseWrapper>;
// (undocumented)
fetchPodMetricsByNamespace(
clusterDetails: ClusterDetails,
namespace: string,
): Promise<PodStatus[]>;
}
// Warning: (ae-missing-release-tag) "KubernetesObjectsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -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
+1 -1
View File
@@ -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",
@@ -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,
},
]);
@@ -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,
};
@@ -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);
@@ -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<GKEClusterDetails[]> {
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(
@@ -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,
},
@@ -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<PodStatus[]> {
return Promise.resolve([]);
},
fetchObjectsForService(
_params: ObjectFetchParams,
): Promise<FetchResponseWrapper> {
@@ -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',
@@ -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) {
@@ -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',
@@ -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<string> = new Set<string>(
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,
};
@@ -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',
@@ -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<PodStatus[]> {
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,
@@ -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<FetchResponseWrapper>;
fetchPodMetricsByNamespace(
clusterDetails: ClusterDetails,
namespace: string,
): Promise<PodStatus[]>;
}
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.
+42 -2
View File
@@ -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<ExtensionsV1beta1Ingress>;
resources: Array<V1Ingress>;
// (undocumented)
type: 'ingresses';
}
+1 -1
View File
@@ -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"
+22 -2
View File
@@ -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<ExtensionsV1beta1Ingress>;
resources: Array<V1Ingress>;
}
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[];
}
+1
View File
@@ -66,6 +66,7 @@ class MockKubernetesClient implements KubernetesApi {
{
cluster: { name: 'mock-cluster' },
resources: this.resources,
podMetrics: [],
errors: [],
},
],
+1 -1
View File
@@ -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",
@@ -46,6 +46,7 @@ describe('Cluster', () => {
resources: oneDeployment.pods,
},
],
podMetrics: [],
errors: [],
},
podsWithErrors: new Set<string>(),
@@ -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<string, ClientPodStatus>());
return (
<ClusterContext.Provider value={clusterObjects.cluster}>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
<Grid item>
<CustomResources />
</Grid>
<Grid item>
<DeploymentsAccordions />
</Grid>
<Grid item>
<IngressesAccordions />
</Grid>
<Grid item>
<ServicesAccordions />
<PodNamesWithMetricsContext.Provider value={podNameToMetrics}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
<Grid item>
<CustomResources />
</Grid>
<Grid item>
<DeploymentsAccordions />
</Grid>
<Grid item>
<IngressesAccordions />
</Grid>
<Grid item>
<ServicesAccordions />
</Grid>
</Grid>
<Grid item>
<CronJobsAccordions />
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</PodNamesWithMetricsContext.Provider>
</GroupedResponsesContext.Provider>
</ClusterContext.Provider>
);
@@ -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<string>());
const { getByText } = render(
wrapper(wrapInTestApp(<CronJobsAccordions />)),
@@ -36,7 +36,7 @@ describe('CronJobsAccordions', () => {
});
it('should render 1 suspended cronjobs', async () => {
const wrapper = kubernetesProviders(twoCronJobsFixture, []);
const wrapper = kubernetesProviders(twoCronJobsFixture, new Set<string>());
const { getByText } = render(
wrapper(wrapInTestApp(<CronJobsAccordions />)),
@@ -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 = ({
/>
</div>
<div>
<PodsTable pods={ownedPods} />
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</div>
</div>
</AccordionDetails>
@@ -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<V1Pod>[] = [
{
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 = ({
/>
</AccordionSummary>
<AccordionDetails>
<PodsTable pods={ownedPods} extraColumns={deploymentPodColumns} />
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</AccordionDetails>
</Accordion>
);
@@ -51,6 +51,7 @@ describe('ErrorPanel', () => {
name: 'THIS_CLUSTER',
},
resources: [],
podMetrics: [],
errors: [
{
errorType: 'SYSTEM_ERROR',
@@ -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 || {};
}}
>
@@ -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) => {
@@ -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<string>());
const jobs: V1Job[] = oneCronJobsFixture.jobs.map(
job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job,
@@ -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: [],
},
],
+95 -20
View File
@@ -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<V1Pod>[] = [
{
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(
<PodsTable pods={[pod as any]} extraColumns={extraColumns} />,
<PodsTable pods={[pod as any]} extraColumns={[READY_COLUMNS]} />,
),
);
@@ -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<string, ClientPodStatus>();
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(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
),
);
// 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<string, ClientPodStatus>();
const wrapper = kubernetesProviders(
undefined,
undefined,
podNameToClientPodStatus,
);
const { getByText, getAllByText } = render(
wrapper(
wrapInTestApp(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
),
);
// 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(
<PodsTable pods={[crashingPod as any]} extraColumns={extraColumns} />,
<PodsTable
pods={[crashingPod as any]}
extraColumns={[READY_COLUMNS]}
/>,
),
);
+69 -8
View File
@@ -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<V1Pod>[] = [
{
@@ -36,13 +53,57 @@ const DEFAULT_COLUMNS: TableColumn<V1Pod>[] = [
},
];
type PodsTablesProps = {
pods: V1Pod[];
extraColumns?: TableColumn<V1Pod>[];
children?: React.ReactNode;
};
const READY: TableColumn<V1Pod>[] = [
{
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<V1Pod>[] = [...DEFAULT_COLUMNS];
if (extraColumns.includes(READY_COLUMNS)) {
columns.push(...READY);
}
if (extraColumns.includes(RESOURCE_COLUMNS)) {
const resourceColumns: TableColumn<V1Pod>[] = [
{
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) => {
<Table
options={{ paging: true, search: false }}
data={pods}
columns={DEFAULT_COLUMNS.concat(extraColumns)}
columns={columns}
/>
</div>
);
@@ -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',
@@ -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<string, ClientPodStatus>
>(new Map<string, ClientPodStatus>());
+13 -5
View File
@@ -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<string> = new Set<string>(),
podNameToMetrics: Map<string, ClientPodStatus> = new Map<
string,
ClientPodStatus
>(),
) => {
return (node: React.ReactNode) => (
<>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
{node}
</PodNamesWithErrorsContext.Provider>
<PodNamesWithMetricsContext.Provider value={podNameToMetrics}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
{node}
</PodNamesWithErrorsContext.Provider>
</PodNamesWithMetricsContext.Provider>
</GroupedResponsesContext.Provider>
</>
);
+2 -2
View File
@@ -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[];
+53
View File
@@ -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(
<SubvalueCell subvalue="limits: 50%" value="requests: 99%" />,
);
});
});
});
+65 -2
View File
@@ -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, <StatusAborted />];
};
// 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 (
<SubvalueCell
value={`requests: ${currentToDeclaredResourceToPerc(
currentUsage,
cpuUtil.requestTotal,
)}`}
subvalue={`limits: ${currentToDeclaredResourceToPerc(
currentUsage,
cpuUtil.limitTotal,
)}`}
/>
);
};
export const podStatusToMemoryUtil = (
podStatus: ClientPodStatus,
): ReactNode => {
const memUtil = podStatus.memory;
return (
<SubvalueCell
value={`requests: ${currentToDeclaredResourceToPerc(
memUtil.currentUsage,
memUtil.requestTotal,
)}`}
subvalue={`limits: ${currentToDeclaredResourceToPerc(
memUtil.currentUsage,
memUtil.limitTotal,
)}`}
/>
);
};
+11 -11
View File
@@ -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"