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
+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.