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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ class MockKubernetesClient implements KubernetesApi {
|
||||
{
|
||||
cluster: { name: 'mock-cluster' },
|
||||
resources: this.resources,
|
||||
podMetrics: [],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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>());
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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%" />,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user