From 5a9a7e7c2675291dbd1a87ab6e10045bb6ace668 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 11 Jan 2021 16:50:30 +0000 Subject: [PATCH] Kubernetes plugin: UI revamp (#3918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial revamp, no error reporting * initial error detection * more error detection * add microsite page * minor fixes * add HPA error detection * refactor error reporting; add tests * fix import * add changeseet * empty error state * prettier yaml file * Update plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx Co-authored-by: Patrik Oldsberg * Update plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx Co-authored-by: Patrik Oldsberg * PR feedback * revert odd change * make patch change * fix test * Apply suggestions from code review Co-authored-by: Fredrik Adelöw * prettier * pr feedback Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw --- .changeset/flat-cycles-lay.md | 6 + .../kubernetes-in-backstage.md | 132 ++++++++ microsite/sidebars.json | 1 + .../dice-roller/dice-roller-manifests.yaml | 131 ++++++++ plugins/kubernetes/assets/emptystate.svg | 1 + plugins/kubernetes/package.json | 1 + .../components/ConfigMaps/ConfigMaps.test.tsx | 41 --- .../src/components/ConfigMaps/ConfigMaps.tsx | 51 ---- .../ConfigMaps/__fixtures__/configmap.json | 46 --- .../DeploymentTables/DeploymentTables.tsx | 220 -------------- .../DeploymentDrawer.test.tsx | 50 ++++ .../DeploymentDrawer.tsx | 72 +++++ .../DeploymentsAccordions.test.tsx} | 26 +- .../DeploymentsAccordions.tsx | 221 ++++++++++++++ .../__fixtures__/2-deployments.json | 84 ++++++ .../index.ts | 2 +- .../ErrorReporting/ErrorReporting.tsx | 135 +++++++++ .../{Ingresses => ErrorReporting}/index.ts | 2 +- ...=> HorizontalPodAutoscalerDrawer.test.tsx} | 42 ++- .../HorizontalPodAutoscalerDrawer.tsx | 51 ++++ .../HorizontalPodAutoscalers.tsx | 64 ---- .../horizontalpodautoscalers.json | 5 +- .../HorizontalPodAutoscalers/index.ts | 2 +- .../components/Ingresses/Ingresses.test.tsx | 47 --- .../src/components/Ingresses/Ingresses.tsx | 51 ---- .../Ingresses/__fixtures__/ingress.json | 87 ------ .../KubernetesContent/ErrorPanel.tsx | 2 +- .../KubernetesContent/KubernetesContent.tsx | 253 ++++++++-------- .../KubernetesDrawer/KubernetesDrawer.tsx | 205 +++++++++++++ .../src/components/Pods/PodDrawer.test.tsx | 91 ++++++ .../src/components/Pods/PodDrawer.tsx | 69 +++++ .../src/components/Pods/PodsTable.test.tsx | 67 +++++ .../src/components/Pods/PodsTable.tsx | 74 +++++ .../Pods/__fixtures__/crashing-pod.json | 233 +++++++++++++++ .../src/components/Pods/__fixtures__/pod.json | 139 +++++++++ .../components/{Services => Pods}/index.ts | 3 +- .../src/components/Services/Services.test.tsx | 54 ---- .../src/components/Services/Services.tsx | 62 ---- .../Services/__fixtures__/services.json | 164 ---------- .../__fixtures__/deploy-bad.json | 119 ++++++++ .../__fixtures__/deploy-healthy.json | 92 ++++++ .../__fixtures__/hpa-healthy.json | 32 ++ .../__fixtures__/hpa-maxed-out.json | 32 ++ .../__fixtures__/pod-crashing.json | 233 +++++++++++++++ .../__fixtures__/pod-missing-cm.json | 168 +++++++++++ .../src/error-detection/__fixtures__/pod.json | 139 +++++++++ .../kubernetes/src/error-detection/common.ts | 70 +++++ .../src/error-detection/deployments.ts | 49 +++ .../error-detection/error-detection.test.ts | 282 ++++++++++++++++++ .../src/error-detection/error-detection.ts | 58 ++++ .../kubernetes/src/error-detection/hpas.ts | 50 ++++ .../ConfigMaps => error-detection}/index.ts | 4 +- .../kubernetes/src/error-detection/pods.ts | 95 ++++++ .../kubernetes/src/error-detection/types.ts | 48 +++ plugins/kubernetes/src/types/types.ts | 19 +- plugins/kubernetes/src/utils/pod.tsx | 104 +++++++ plugins/kubernetes/src/utils/response.ts | 62 ++++ 57 files changed, 3589 insertions(+), 1054 deletions(-) create mode 100644 .changeset/flat-cycles-lay.md create mode 100644 docs/features/software-catalog/kubernetes-in-backstage.md create mode 100644 plugins/kubernetes/assets/emptystate.svg delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json delete mode 100644 plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx rename plugins/kubernetes/src/components/{DeploymentTables/DeploymentTables.test.tsx => DeploymentsAccordions/DeploymentsAccordions.test.tsx} (57%) create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx rename plugins/kubernetes/src/components/{DeploymentTables => DeploymentsAccordions}/__fixtures__/2-deployments.json (97%) rename plugins/kubernetes/src/components/{DeploymentTables => DeploymentsAccordions}/index.ts (90%) create mode 100644 plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx rename plugins/kubernetes/src/components/{Ingresses => ErrorReporting}/index.ts (92%) rename plugins/kubernetes/src/components/HorizontalPodAutoscalers/{HorizontalPodAutoscalers.test.tsx => HorizontalPodAutoscalerDrawer.test.tsx} (59%) create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx delete mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json create mode 100644 plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodDrawer.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodsTable.test.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodsTable.tsx create mode 100644 plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json create mode 100644 plugins/kubernetes/src/components/Pods/__fixtures__/pod.json rename plugins/kubernetes/src/components/{Services => Pods}/index.ts (87%) delete mode 100644 plugins/kubernetes/src/components/Services/Services.test.tsx delete mode 100644 plugins/kubernetes/src/components/Services/Services.tsx delete mode 100644 plugins/kubernetes/src/components/Services/__fixtures__/services.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod.json create mode 100644 plugins/kubernetes/src/error-detection/common.ts create mode 100644 plugins/kubernetes/src/error-detection/deployments.ts create mode 100644 plugins/kubernetes/src/error-detection/error-detection.test.ts create mode 100644 plugins/kubernetes/src/error-detection/error-detection.ts create mode 100644 plugins/kubernetes/src/error-detection/hpas.ts rename plugins/kubernetes/src/{components/ConfigMaps => error-detection}/index.ts (82%) create mode 100644 plugins/kubernetes/src/error-detection/pods.ts create mode 100644 plugins/kubernetes/src/error-detection/types.ts create mode 100644 plugins/kubernetes/src/utils/pod.tsx create mode 100644 plugins/kubernetes/src/utils/response.ts diff --git a/.changeset/flat-cycles-lay.md b/.changeset/flat-cycles-lay.md new file mode 100644 index 0000000000..2f84b38ffb --- /dev/null +++ b/.changeset/flat-cycles-lay.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +--- + +Revamped Kubernetes UI and added error reporting/detection diff --git a/docs/features/software-catalog/kubernetes-in-backstage.md b/docs/features/software-catalog/kubernetes-in-backstage.md new file mode 100644 index 0000000000..e9fe00718a --- /dev/null +++ b/docs/features/software-catalog/kubernetes-in-backstage.md @@ -0,0 +1,132 @@ +--- +id: kubernetes-in-backstage +title: Kubernetes in Backstage +description: Monitoring Kubernetes based services with the service catalog +--- + +# Kubernetes in Backstage + +Kubernetes in Backstage is a way to monitor your service's current status when +it is deployed on Kubernetes. + +## Configuration + +Example: + +```yaml +kubernetes: + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + serviceAccountToken: TOKEN + authProvider: 'serviceAccount' + - url: http://127.0.0.2:9999 + name: gke-cluster-1 + authProvider: 'google' +``` + +### serviceLocatorMethod + +This configures how to determine which clusters a component is running in. + +Currently, the only valid serviceLocatorMethod is: + +### multiTenant + +This configuration assumes that all components run on all the provided clusters. + +### clusterLocatorMethods + +This is used to determine where to retrieve cluster configuration from. + +Currently, the only valid cluster locator method is: + +`"config"` + +This cluster locator method will read cluster information from your app-config +(see below). + +### clusters + +Used by the `config` cluster locator method to construct Kubernetes clients. + +### clusters.\*.url + +The base URL to the Kubernetes control plane. Can be found by using the +`Kubernetes master` result from running the `kubectl cluster-info` command. + +### clusters.\*.name + +A name to represent this cluster, this must be unique within the `clusters` +array. Users will see this value in the Service Catalog Kubernetes plugin. + +### clusters.\*.authProvider + +This determines how the Kubernetes client authenticates with the Kubernetes +cluster. Valid values are: + +| Value | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | +| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | + +### clusters.\*.serviceAccount (optional) + +The service account token to be used when using the `serviceAccount` auth +provider. + +## RBAC + +The current RBAC permissions required are read-only cluster wide, for the +following objects: + +- pods +- services +- configmaps +- deployments +- replicasets +- horizontalpodautoscalers +- ingresses + +## Surfacing your Kubernetes components as part of an entity + +There are two ways to surface your kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Common `backstage.io/kubernetes-id` label + +#### Adding the entity annotation + +In order for Backstage to detect that an entity has Kubernetes components, the +following annotation should be added to the entity. + +```yaml +annotations: + 'backstage.io/kubernetes-id': dice-roller +``` + +#### Labeling Kubernetes components + +In order for Kubernetes components to show up in the service catalog as a part +of an entity, Kubernetes components must be labeled with the following label: + +```yaml +'backstage.io/kubernetes-id': +``` + +### label selector query annotation + +#### Adding a label selector query annotation + +You can write your own custom label selector query that backstage will use to +lookup the objects (similar to `kubectl --selector="your query here"`). Review +the documentation +[here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) +for more info. + +```yaml +'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index b31f0ccdf3..d9da0ae449 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -50,6 +50,7 @@ "features/software-catalog/well-known-relations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", + "features/software-catalog/kubernetes-in-backstage", "features/software-catalog/software-catalog-api" ] }, diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index 50956bd7d3..68ef489170 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -18,6 +18,17 @@ spec: containers: - name: nginx image: nginx:1.14.2 + args: + - bash + - -c + - yes > /dev/null & yes > /dev/null & yes > /dev/null + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '50m' ports: - containerPort: 80 @@ -44,14 +55,134 @@ spec: image: nginx:1.14.2 ports: - containerPort: 80 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' - name: side-car image: nginx:1.14.2 ports: - containerPort: 81 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' - name: other-side-car image: nginx:1.14.2 ports: - containerPort: 82 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-bad-config + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-bad-config + replicas: 2 + template: + metadata: + labels: + app: dice-roller-bad-config + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:6000000 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + ports: + - containerPort: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-big + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-big + replicas: 1 + template: + metadata: + labels: + app: dice-roller-big + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + resources: + requests: + memory: '64Mi' + cpu: '100000m' + limits: + memory: '128Mi' + cpu: '100000m' + ports: + - containerPort: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-bad-cm + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-bad-cm + replicas: 1 + template: + metadata: + labels: + app: dice-roller-bad-cm + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: SOME_ENV_VAR + valueFrom: + configMapKeyRef: + name: some-cm + key: some-key + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + ports: + - containerPort: 80 --- apiVersion: autoscaling/v1 diff --git a/plugins/kubernetes/assets/emptystate.svg b/plugins/kubernetes/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/kubernetes/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 56a61d12a2..f00c846163 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -40,6 +40,7 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "js-yaml": "^4.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx deleted file mode 100644 index 35b8f2143a..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { render } from '@testing-library/react'; -import { ConfigMaps } from './ConfigMaps'; -import * as configmapFixture from './__fixtures__/configmap.json'; -import { wrapInTestApp } from '@backstage/test-utils'; - -describe('ConfigMaps', () => { - it('should render configmap', async () => { - const { getByText } = render( - wrapInTestApp( - , - ), - ); - - // title - expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('Config Map')).toBeInTheDocument(); - - // values - expect(getByText('Immutable')).toBeInTheDocument(); - expect(getByText('false')).toBeInTheDocument(); - expect(getByText('Data')).toBeInTheDocument(); - expect(getByText('Foo: bar')).toBeInTheDocument(); // TODO wish this wasn't upper case - }); -}); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx deleted file mode 100644 index 5a013eb2c6..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Grid } from '@material-ui/core'; -import { V1ConfigMap } from '@kubernetes/client-node'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; - -type ConfigMapsProps = { - configMaps: V1ConfigMap[]; - children?: React.ReactNode; -}; - -export const ConfigMaps = ({ configMaps }: ConfigMapsProps) => { - return ( - - {configMaps.map((cm, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json deleted file mode 100644 index 77ea844611..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "data": { - "foo": "bar" - }, - "metadata": { - "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"bar\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"}}\n" - }, - "creationTimestamp": "2020-09-24T11:39:26.000Z", - "labels": { - "backstage.io/kubernetes-id": "dice-roller" - }, - "managedFields": [ - { - "apiVersion": "v1", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:data": { - ".": {}, - "f:foo": {} - }, - "f:metadata": { - "f:annotations": { - ".": {}, - "f:kubectl.kubernetes.io/last-applied-configuration": {} - }, - "f:labels": { - ".": {}, - "f:backstage.io/kubernetes-id": {} - } - } - }, - "manager": "kubectl", - "operation": "Update", - "time": "2020-09-24T11:39:26.000Z" - } - ], - "name": "dice-roller", - "namespace": "default", - "resourceVersion": "503867", - "selfLink": "/api/v1/namespaces/default/configmaps/dice-roller", - "uid": "e9efe5ee-53b9-4422-aef2-877a03c73d5f" - } - } -] diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx deleted file mode 100644 index a009c20d7e..0000000000 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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, { Fragment } from 'react'; -import { Chip, Grid } from '@material-ui/core'; -import { - StatusAborted, - StatusError, - StatusOK, - SubvalueCell, - Table, - TableColumn, -} from '@backstage/core'; -import { - V1ComponentCondition, - V1Deployment, - V1Pod, - V1ReplicaSet, -} from '@kubernetes/client-node'; -import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference'; -import { DeploymentTriple } from '../../types/types'; - -const renderCondition = (condition: V1ComponentCondition | undefined) => { - if (!condition) { - return ; - } - - const status = condition.status; - - if (status === 'True') { - return ; - } else if (status === 'False') { - return ; - } - return ; -}; - -const columns: TableColumn[] = [ - { - title: 'name', - highlight: true, - width: '20%', - render: (pod: V1Pod) => pod.metadata?.name ?? 'un-named pod', - }, - { - title: 'images', - width: '20%', - render: (pod: V1Pod) => { - const containerStatuses = pod.status?.containerStatuses ?? []; - return containerStatuses.map((cs, i) => { - return ; - }); - }, - }, - { - title: 'phase', - render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', - }, - { - title: 'containers ready', - align: 'center', - render: (pod: V1Pod) => { - const containerStatuses = pod.status?.containerStatuses ?? []; - const containersReady = containerStatuses.filter(cs => cs.ready).length; - - return `${containersReady}/${containerStatuses.length}`; - }, - }, - { - title: 'total restarts', - render: (pod: V1Pod) => { - const containerStatuses = pod.status?.containerStatuses ?? []; - return containerStatuses?.reduce((a, b) => a + b.restartCount, 0); - }, - type: 'numeric', - }, - { - title: 'status', - width: '20%', - render: (pod: V1Pod) => { - const containerStatuses = pod.status?.containerStatuses ?? []; - const errors = containerStatuses.reduce((accum, next) => { - if (next.state === undefined) { - return accum; - } - - const waiting = next.state.waiting; - const terminated = next.state.terminated; - - const renderCell = (reason: string | undefined) => ( - - Container: {next.name}} - subvalue={reason} - /> -
- - ); - - if (waiting) { - accum.push(renderCell(waiting.reason)); - } - - if (terminated) { - accum.push(renderCell(terminated.reason)); - } - - return accum; - }, [] as React.ReactNode[]); - - if (errors.length === 0) { - return OK; - } - - return errors; - }, - }, - { - title: 'Pod Initialized', - align: 'center', - render: (pod: V1Pod) => { - const conditions = pod.status?.conditions ?? []; - return renderCondition(conditions.find(c => c.type === 'Initialized')); - }, - }, - { - title: 'Pod Ready', - align: 'center', - render: (pod: V1Pod) => { - const conditions = pod.status?.conditions ?? []; - return renderCondition(conditions.find(c => c.type === 'Ready')); - }, - }, - { - title: 'Containers Ready', - align: 'center', - render: (pod: V1Pod) => { - const conditions = pod.status?.conditions ?? []; - return renderCondition( - conditions.find(c => c.type === 'ContainersReady'), - ); - }, - }, - { - title: 'Pod Scheduled', - align: 'center', - render: (pod: V1Pod) => { - const conditions = pod.status?.conditions ?? []; - return renderCondition(conditions.find(c => c.type === 'PodScheduled')); - }, - }, -]; - -type DeploymentTablesProps = { - deploymentTriple: DeploymentTriple; - children?: React.ReactNode; -}; - -export const DeploymentTables = ({ - deploymentTriple, -}: DeploymentTablesProps) => { - const isOwnedBy = ( - ownerReferences: V1OwnerReference[], - obj: V1Pod | V1ReplicaSet | V1Deployment, - ): boolean => { - return ownerReferences?.some(or => or.name === obj.metadata?.name); - }; - - return ( - - {deploymentTriple.deployments.map((deployment, i) => ( - - {deploymentTriple.replicaSets - // Filter out replica sets with no replicas - .filter(rs => rs.status && rs.status.replicas > 0) - // Find the replica sets this deployment owns - .filter(rs => - isOwnedBy(rs.metadata?.ownerReferences ?? [], deployment), - ) - .map((rs, j) => { - // Find the pods this replica set owns and render them in the table - const ownedPods = deploymentTriple.pods.filter(pod => - isOwnedBy(pod.metadata?.ownerReferences ?? [], rs), - ); - - return ( - - - - ); - })} - - ))} - - ); -}; diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx new file mode 100644 index 0000000000..5bf2c16de2 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { render } from '@testing-library/react'; +import * as deployments from './__fixtures__/2-deployments.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { DeploymentDrawer } from './DeploymentDrawer'; + +describe('DeploymentDrawer', () => { + it('should render deployment drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('dice-roller')).toHaveLength(2); + expect(getAllByText('Deployment')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Strategy')).toBeInTheDocument(); + expect(getByText('Rolling Update:')).toBeInTheDocument(); + expect(getByText('Max Surge: 25%')).toBeInTheDocument(); + expect(getByText('Max Unavailable: 25%')).toBeInTheDocument(); + expect(getByText('Type: RollingUpdate')).toBeInTheDocument(); + expect(getByText('Min Ready Seconds')).toBeInTheDocument(); + expect(getByText('???')).toBeInTheDocument(); + expect(getByText('Progress Deadline Seconds')).toBeInTheDocument(); + expect(getByText('600')).toBeInTheDocument(); + expect(getByText('Progressing')).toBeInTheDocument(); + expect(getByText('Available')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx new file mode 100644 index 0000000000..6bacad9b16 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1Deployment } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { renderCondition } from '../../utils/pod'; +import { Typography, Grid } from '@material-ui/core'; + +export const DeploymentDrawer = ({ + deployment, + expanded, +}: { + deployment: V1Deployment; + expanded?: boolean; +}) => { + return ( + { + const conditions = (deployment.status?.conditions ?? []) + .map(renderCondition) + .reduce((accum, next) => { + accum[next[0]] = next[1]; + return accum; + }, {} as { [key: string]: React.ReactNode }); + + return { + strategy: deployment.spec?.strategy ?? '???', + minReadySeconds: deployment.spec?.minReadySeconds ?? '???', + progressDeadlineSeconds: + deployment.spec?.progressDeadlineSeconds ?? '???', + ...conditions, + }; + }} + > + + + + {deployment.metadata?.name ?? 'unknown object'} + + + + + Deployment + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx similarity index 57% rename from plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx rename to plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx index 3dacac7055..a522af6d4f 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx @@ -16,26 +16,32 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { DeploymentTables } from './DeploymentTables'; +import { DeploymentsAccordions } from './DeploymentsAccordions'; import * as twoDeployFixture from './__fixtures__/2-deployments.json'; import { wrapInTestApp } from '@backstage/test-utils'; -describe('DeploymentTables', () => { +describe('DeploymentsAccordions', () => { it('should render 2 deployments', async () => { const { getByText } = render( wrapInTestApp( - , + , ), ); - // title expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('dice-roller-canary')).toBeInTheDocument(); + expect(getByText('10 pods')).toBeInTheDocument(); + expect(getByText('No pods with errors')).toBeInTheDocument(); + expect(getByText('min replicas 10 / max replicas 15')).toBeInTheDocument(); + expect(getByText('current CPU usage: 30%')).toBeInTheDocument(); + expect(getByText('target CPU usage: 50%')).toBeInTheDocument(); - // pod names - expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); - expect( - getByText('dice-roller-canary-7d64cd756c-55rfq'), - ).toBeInTheDocument(); + expect(getByText('dice-roller-canary')).toBeInTheDocument(); + expect(getByText('2 pods')).toBeInTheDocument(); + expect(getByText('1 pod with errors')).toBeInTheDocument(); }); }); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx new file mode 100644 index 0000000000..c2bda7a895 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { DeploymentResources } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference'; +import { + V1Deployment, + V1Pod, + V1ReplicaSet, + V1HorizontalPodAutoscaler, +} from '@kubernetes/client-node'; +import { StatusError, StatusOK } from '@backstage/core'; +import { PodsTable } from '../Pods'; +import { DeploymentDrawer } from './DeploymentDrawer'; +import { HorizontalPodAutoscalerDrawer } from '../HorizontalPodAutoscalers'; + +type DeploymentsAccordionsProps = { + deploymentResources: DeploymentResources; + clusterPodNamesWithErrors: Set; + children?: React.ReactNode; +}; + +export const DeploymentsAccordions = ({ + deploymentResources, + clusterPodNamesWithErrors, +}: DeploymentsAccordionsProps) => { + const isOwnedBy = ( + ownerReferences: V1OwnerReference[], + obj: V1Pod | V1ReplicaSet | V1Deployment, + ): boolean => { + return ownerReferences?.some(or => or.name === obj.metadata?.name); + }; + + return ( + + {deploymentResources.deployments.map((deployment, i) => ( + + {deploymentResources.replicaSets + // Filter out replica sets with no replicas + .filter(rs => rs.status && rs.status.replicas > 0) + // Find the replica sets this deployment owns + .filter(rs => + isOwnedBy(rs.metadata?.ownerReferences ?? [], deployment), + ) + .map((rs, j) => { + // Find the pods this replica set owns and render them in the table + const ownedPods = deploymentResources.pods.filter(pod => + isOwnedBy(pod.metadata?.ownerReferences ?? [], rs), + ); + + const matchingHpa = deploymentResources.horizontalPodAutoscalers.find( + (hpa: V1HorizontalPodAutoscaler) => { + return ( + (hpa.spec?.scaleTargetRef?.kind ?? '').toLowerCase() === + 'deployment' && + (hpa.spec?.scaleTargetRef?.name ?? '') === + (deployment.metadata?.name ?? 'unknown-deployment') + ); + }, + ); + + return ( + + + + ); + })} + + ))} + + ); +}; + +type DeploymentAccordionProps = { + deployment: V1Deployment; + ownedPods: V1Pod[]; + matchingHpa?: V1HorizontalPodAutoscaler; + clusterPodNamesWithErrors: Set; + children?: React.ReactNode; +}; + +const DeploymentAccordion = ({ + deployment, + ownedPods, + matchingHpa, + clusterPodNamesWithErrors, +}: DeploymentAccordionProps) => { + const podsWithErrors = ownedPods.filter(p => + clusterPodNamesWithErrors.has(p.metadata?.name ?? ''), + ); + + return ( + + }> + + + + + + + ); +}; + +type DeploymentSummaryProps = { + deployment: V1Deployment; + numberOfCurrentPods: number; + numberOfPodsWithErrors: number; + hpa?: V1HorizontalPodAutoscaler; + children?: React.ReactNode; +}; + +const DeploymentSummary = ({ + deployment, + numberOfCurrentPods, + numberOfPodsWithErrors, + hpa, +}: DeploymentSummaryProps) => { + return ( + + + + + + + + {hpa && ( + + + + + + min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '} + {hpa.spec?.maxReplicas ?? '?'} + + + + + current CPU usage:{' '} + {hpa.status?.currentCPUUtilizationPercentage ?? '?'}% + + + + + target CPU usage:{' '} + {hpa.spec?.targetCPUUtilizationPercentage ?? '?'}% + + + + + + )} + + + {numberOfCurrentPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + + {numberOfPodsWithErrors} pod + {numberOfPodsWithErrors > 1 ? 's' : ''} with errors + + ) : ( + No pods with errors + )} + + + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json similarity index 97% rename from plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json rename to plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json index e51d0d0b5f..f5efdbf1cb 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json @@ -4431,5 +4431,89 @@ "updatedReplicas": 2 } } + ], + "horizontalPodAutoscalers": [ + { + "apiVersion": "autoscaling/v1", + "kind": "HorizontalPodAutoscaler", + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: no metrics returned from resource metrics API\"}]", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n" + }, + "creationTimestamp": "2021-01-05T10:25:48Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:maxReplicas": {}, + "f:minReplicas": {}, + "f:scaleTargetRef": { + "f:apiVersion": {}, + "f:kind": {}, + "f:name": {} + }, + "f:targetCPUUtilizationPercentage": {} + } + }, + "manager": "kubectl-client-side-apply", + "operation": "Update", + "time": "2021-01-05T10:25:48Z" + }, + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:autoscaling.alpha.kubernetes.io/conditions": {} + } + }, + "f:status": { + "f:currentReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2021-01-05T10:26:04Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "598", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "dd7c5329-567c-43c2-b159-756808d90a8e" + }, + "spec": { + "maxReplicas": 15, + "minReplicas": 10, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 50 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 0, + "currentCPUUtilizationPercentage": 30 + } + } ] } diff --git a/plugins/kubernetes/src/components/DeploymentTables/index.ts b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts similarity index 90% rename from plugins/kubernetes/src/components/DeploymentTables/index.ts rename to plugins/kubernetes/src/components/DeploymentsAccordions/index.ts index dac38c5c81..923fec624b 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/index.ts +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { DeploymentTables } from './DeploymentTables'; +export { DeploymentsAccordions } from './DeploymentsAccordions'; diff --git a/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx new file mode 100644 index 0000000000..c986c0158d --- /dev/null +++ b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 * as React from 'react'; +import { Table, TableColumn, InfoCard } from '@backstage/core'; +import { DetectedError, DetectedErrorsByCluster } from '../../error-detection'; +import { Chip, Typography, Grid } from '@material-ui/core'; +import EmptyStateImage from '../../../assets/emptystate.svg'; + +type ErrorReportingProps = { + detectedErrors: DetectedErrorsByCluster; +}; + +const columns: TableColumn[] = [ + { + title: 'cluster', + width: '15%', + render: (detectedError: DetectedError) => detectedError.cluster, + }, + { + title: 'kind', + width: '15%', + render: (detectedError: DetectedError) => detectedError.kind, + }, + { + title: 'name', + width: '30%', + render: (detectedError: DetectedError) => { + const errorCount = detectedError.names.length; + + if (errorCount === 0) { + // This shouldn't happen + return null; + } + + const displayName = detectedError.names[0]; + + const otherErrorCount = errorCount - 1; + + return ( + <> + {displayName}{' '} + {otherErrorCount > 0 && ( + 1 ? 's' : '' + }`} + size="small" + /> + )} + + ); + }, + }, + { + title: 'messages', + width: '40%', + render: (detectedError: DetectedError) => ( + <> + {detectedError.message.map((m, i) => ( +
{m}
+ ))} + + ), + }, +]; + +const sortBySeverity = (a: DetectedError, b: DetectedError) => { + if (a.severity < b.severity) { + return 1; + } else if (b.severity < a.severity) { + return -1; + } + return 0; +}; + +export const ErrorReporting = ({ detectedErrors }: ErrorReportingProps) => { + const errors = Array.from(detectedErrors.values()) + .flat() + .sort(sortBySeverity); + + return ( + <> + {errors.length === 0 ? ( + + + + ) : ( +
+ )} + + ); +}; + +export const ErrorEmptyState = () => { + return ( + + + + Nice! There are no errors to report! + + + + EmptyState + + + ); +}; diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/kubernetes/src/components/ErrorReporting/index.ts similarity index 92% rename from plugins/kubernetes/src/components/Ingresses/index.ts rename to plugins/kubernetes/src/components/ErrorReporting/index.ts index ecc67480db..87ce11ffd8 100644 --- a/plugins/kubernetes/src/components/Ingresses/index.ts +++ b/plugins/kubernetes/src/components/ErrorReporting/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Ingresses } from './Ingresses'; +export { ErrorReporting } from './ErrorReporting'; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx similarity index 59% rename from plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx rename to plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx index eb5d407c76..fc64de0f0a 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx @@ -16,37 +16,35 @@ import React from 'react'; import { render } from '@testing-library/react'; -import * as hpaFixture from './__fixtures__/horizontalpodautoscalers.json'; +import * as hpas from './__fixtures__/horizontalpodautoscalers.json'; import { wrapInTestApp } from '@backstage/test-utils'; -import { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; +import { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer'; -describe('HorizontalPodAutoscalers', () => { - it('should render horizontalpodautoscaler', async () => { - const { getByText, getAllByText } = render( +describe('HorizontalPodAutoscalersDrawer', () => { + it('should render hpa drawer', async () => { + const { getByText } = render( wrapInTestApp( - , + +

CHILD

+
, ), ); - // title expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('Horizontal Pod Autoscaler')).toBeInTheDocument(); - - expect(getByText('Scaling Target')).toBeInTheDocument(); - expect(getByText('Api Version: apps/v1')).toBeInTheDocument(); - expect(getByText('Kind: Deployment')).toBeInTheDocument(); - expect(getByText('Name: dice-roller')).toBeInTheDocument(); - expect(getByText('Min Replicas')).toBeInTheDocument(); - expect(getByText('Max Replicas')).toBeInTheDocument(); - expect(getByText('15')).toBeInTheDocument(); - expect(getByText('Current Replicas')).toBeInTheDocument(); - expect(getByText('Desired Replicas')).toBeInTheDocument(); - expect(getByText('0')).toBeInTheDocument(); + expect(getByText('CHILD')).toBeInTheDocument(); + expect(getByText('HorizontalPodAutoscaler')).toBeInTheDocument(); + expect(getByText('YAML')).toBeInTheDocument(); expect(getByText('Target CPU Utilization Percentage')).toBeInTheDocument(); expect(getByText('50')).toBeInTheDocument(); expect(getByText('Current CPU Utilization Percentage')).toBeInTheDocument(); - expect(getByText('Last Scale Time')).toBeInTheDocument(); - expect(getAllByText('unknown')).toHaveLength(2); - expect(getAllByText('10')).toHaveLength(2); + expect(getByText('30')).toBeInTheDocument(); + expect(getByText('Min Replicas')).toBeInTheDocument(); + expect(getByText('10')).toBeInTheDocument(); + expect(getByText('Max Replicas')).toBeInTheDocument(); + expect(getByText('15')).toBeInTheDocument(); + expect(getByText('Current Replicas')).toBeInTheDocument(); + expect(getByText('13')).toBeInTheDocument(); + expect(getByText('Desired Replicas')).toBeInTheDocument(); + expect(getByText('14')).toBeInTheDocument(); }); }); diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx new file mode 100644 index 0000000000..00ab2cad84 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; + +export const HorizontalPodAutoscalerDrawer = ({ + hpa, + expanded, + children, +}: { + hpa: V1HorizontalPodAutoscaler; + expanded?: boolean; + children?: React.ReactNode; +}) => { + return ( + { + return { + targetCPUUtilizationPercentage: + hpa.spec?.targetCPUUtilizationPercentage, + currentCPUUtilizationPercentage: + hpa.status?.currentCPUUtilizationPercentage, + minReplicas: hpa.spec?.minReplicas, + maxReplicas: hpa.spec?.maxReplicas, + currentReplicas: hpa.status?.currentReplicas, + desiredReplicas: hpa.status?.desiredReplicas, + }; + }} + > + {children} + + ); +}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx deleted file mode 100644 index b4540840d2..0000000000 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Grid } from '@material-ui/core'; -import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; -import { orUnknown } from '../../utils'; - -type HorizontalPodAutoscalersProps = { - hpas: V1HorizontalPodAutoscaler[]; - children?: React.ReactNode; -}; - -export const HorizontalPodAutoscalers = ({ - hpas, -}: HorizontalPodAutoscalersProps) => { - return ( - - {hpas.map((hpa, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json index 6bc6028246..6afdda48ed 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json @@ -74,8 +74,9 @@ "targetCPUUtilizationPercentage": 50 }, "status": { - "currentReplicas": 10, - "desiredReplicas": 0 + "currentReplicas": 13, + "desiredReplicas": 14, + "currentCPUUtilizationPercentage": 30 } } ] diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts index 3eebbe9813..b5ca533079 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; +export { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer'; diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx deleted file mode 100644 index f2507a3f39..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { render } from '@testing-library/react'; -import * as ingressesFixture from './__fixtures__/ingress.json'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { Ingresses } from './Ingresses'; - -describe('Ingresses', () => { - it('should render ingress', async () => { - const { getByText } = render( - wrapInTestApp( - , - ), - ); - - // title - expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('Ingress')).toBeInTheDocument(); - - // values - expect(getByText('Backend')).toBeInTheDocument(); - expect(getByText('Ip: 192.168.64.2')).toBeInTheDocument(); - expect(getByText('Rules')).toBeInTheDocument(); - expect(getByText('Host: nginx')).toBeInTheDocument(); - expect(getByText('Http:')).toBeInTheDocument(); - expect(getByText('Paths:')).toBeInTheDocument(); - expect(getByText('Service Name: dice-roller')).toBeInTheDocument(); - expect(getByText('Service Port: 80')).toBeInTheDocument(); - expect(getByText('Path: /')).toBeInTheDocument(); - expect(getByText('Path Type: ImplementationSpecific')).toBeInTheDocument(); - }); -}); diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx deleted file mode 100644 index 37932601a1..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Grid } from '@material-ui/core'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; - -type IngressesProps = { - ingresses: ExtensionsV1beta1Ingress[]; - children?: React.ReactNode; -}; - -export const Ingresses = ({ ingresses }: IngressesProps) => { - return ( - - {ingresses.map((ingress, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json deleted file mode 100644 index fd0bc5ec43..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json +++ /dev/null @@ -1,87 +0,0 @@ -[ - { - "metadata": { - "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1beta1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/rewrite-target\":\"/$1\"},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"rules\":[{\"host\":\"nginx\",\"http\":{\"paths\":[{\"backend\":{\"serviceName\":\"dice-roller\",\"servicePort\":80},\"path\":\"/\"}]}}]}}\n", - "nginx.ingress.kubernetes.io/rewrite-target": "/$1" - }, - "creationTimestamp": "2020-09-28T13:28:00.000Z", - "generation": 1, - "labels": { - "backstage.io/kubernetes-id": "dice-roller" - }, - "managedFields": [ - { - "apiVersion": "networking.k8s.io/v1beta1", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:metadata": { - "f:annotations": { - ".": {}, - "f:kubectl.kubernetes.io/last-applied-configuration": {}, - "f:nginx.ingress.kubernetes.io/rewrite-target": {} - }, - "f:labels": { - ".": {}, - "f:backstage.io/kubernetes-id": {} - } - }, - "f:spec": { - "f:rules": {} - } - }, - "manager": "kubectl", - "operation": "Update", - "time": "2020-09-28T13:28:21.000Z" - }, - { - "apiVersion": "networking.k8s.io/v1beta1", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:status": { - "f:loadBalancer": { - "f:ingress": {} - } - } - }, - "manager": "nginx-ingress-controller", - "operation": "Update", - "time": "2020-09-28T13:28:40.000Z" - } - ], - "name": "dice-roller", - "namespace": "default", - "resourceVersion": "699017", - "selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/dice-roller", - "uid": "e96994c0-49b9-4c1c-8ce0-72c5336fe960" - }, - "spec": { - "rules": [ - { - "host": "nginx", - "http": { - "paths": [ - { - "backend": { - "serviceName": "dice-roller", - "servicePort": 80 - }, - "path": "/", - "pathType": "ImplementationSpecific" - } - ] - } - } - ] - }, - "status": { - "loadBalancer": { - "ingress": [ - { - "ip": "192.168.64.2" - } - ] - } - } - } -] diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx index 1b8336dd72..85060fc11b 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { WarningPanel } from '@backstage/core'; import { Typography } from '@material-ui/core'; -import { ClusterObjects } from '../../../../kubernetes-backend/src'; +import { ClusterObjects } from '@backstage/plugin-kubernetes-backend'; const clustersWithErrorsToErrorMessage = ( clustersWithErrors: ClusterObjects[], diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 65c1102d47..293e7a4833 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -14,89 +14,39 @@ * limitations under the License. */ -import React, { ReactElement, useEffect, useState } from 'react'; -import { Grid, TabProps } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; import { Config } from '@backstage/config'; import { - CardTab, configApiRef, Content, Page, Progress, - TabbedCard, + StatusError, + StatusOK, useApi, } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { - KubernetesRequestBody, ClusterObjects, - FetchResponse, + KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; -import { DeploymentTables } from '../DeploymentTables'; -import { DeploymentTriple } from '../../types/types'; -import { - ExtensionsV1beta1Ingress, - V1ConfigMap, - V1HorizontalPodAutoscaler, - V1Service, -} from '@kubernetes/client-node'; -import { Services } from '../Services'; -import { ConfigMaps } from '../ConfigMaps'; -import { Ingresses } from '../Ingresses'; -import { HorizontalPodAutoscalers } from '../HorizontalPodAutoscalers'; import { ErrorPanel } from './ErrorPanel'; - -interface GroupedResponses extends DeploymentTriple { - services: V1Service[]; - configMaps: V1ConfigMap[]; - horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; - ingresses: ExtensionsV1beta1Ingress[]; -} - -// TODO this could probably be a lodash groupBy -const groupResponses = (fetchResponse: FetchResponse[]) => { - return fetchResponse.reduce( - (prev, next) => { - switch (next.type) { - case 'deployments': - prev.deployments.push(...next.resources); - break; - case 'pods': - prev.pods.push(...next.resources); - break; - case 'replicasets': - prev.replicaSets.push(...next.resources); - break; - case 'services': - prev.services.push(...next.resources); - break; - case 'configmaps': - prev.configMaps.push(...next.resources); - break; - case 'horizontalpodautoscalers': - prev.horizontalPodAutoscalers.push(...next.resources); - break; - case 'ingresses': - prev.ingresses.push(...next.resources); - break; - default: - } - return prev; - }, - { - pods: [], - replicaSets: [], - deployments: [], - services: [], - configMaps: [], - horizontalPodAutoscalers: [], - ingresses: [], - } as GroupedResponses, - ); -}; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { DeploymentsAccordions } from '../DeploymentsAccordions'; +import { ErrorReporting } from '../ErrorReporting'; +import { groupResponses } from '../../utils/response'; +import { DetectedError, detectErrors } from '../../error-detection'; type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; @@ -148,6 +98,11 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { const clustersWithErrors = kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; + const detectedErrors = + kubernetesObjects !== undefined + ? detectErrors(kubernetesObjects) + : new Map(); + return ( @@ -172,11 +127,29 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { /> )} - {kubernetesObjects?.items.map((item, i) => ( - - - - ))} + {kubernetesObjects && ( + <> + + + + + + + + Your Clusters + + + {kubernetesObjects?.items.map((item, i) => ( + + + + ))} + + + )} @@ -185,67 +158,97 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { type ClusterProps = { clusterObjects: ClusterObjects; + detectedErrors?: DetectedError[]; children?: React.ReactNode; }; -const Cluster = ({ clusterObjects }: ClusterProps) => { - const [selectedTab, setSelectedTab] = useState('one'); - - const handleChange = (_ev: any, newSelectedTab: string | number) => - setSelectedTab(newSelectedTab); - +const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); - const configMaps = groupedResponses.configMaps; - const hpas = groupedResponses.horizontalPodAutoscalers; - const ingresses = groupedResponses.ingresses; - - const tabs: ReactElement[] = [ - - - , - - - , - ]; - - if (configMaps.length > 0) { - tabs.push( - - - , - ); - } - if (hpas.length > 0) { - tabs.push( - - - , - ); - } - if (ingresses.length > 0) { - tabs.push( - - - , - ); - } + const podsWithErrors = new Set( + detectedErrors + ?.filter(de => de.kind === 'Pod') + .map(de => de.names) + .flat() ?? [], + ); return ( <> - - {tabs} - + + }> + + + + + + ); }; + +type ClusterSummaryProps = { + clusterName: string; + totalNumberOfPods: number; + numberOfPodsWithErrors: number; + children?: React.ReactNode; +}; + +const ClusterSummary = ({ + clusterName, + totalNumberOfPods, + numberOfPodsWithErrors, +}: ClusterSummaryProps) => { + return ( + + + + {clusterName} + + Cluster + + + + + + + + + {totalNumberOfPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + {numberOfPodsWithErrors} pods with errors + ) : ( + No pods with errors + )} + + + + ); +}; diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx new file mode 100644 index 0000000000..8deb49bf07 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -0,0 +1,205 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { ChangeEvent, useState } from 'react'; +import { + Button, + Typography, + makeStyles, + IconButton, + createStyles, + Theme, + Drawer, + Switch, + FormControlLabel, + Grid, +} from '@material-ui/core'; +import Close from '@material-ui/icons/Close'; +import { V1ObjectMeta } from '@kubernetes/client-node'; +import { withStyles } from '@material-ui/core/styles'; +import { CodeSnippet, StructuredMetadataTable } from '@backstage/core'; +import jsYaml from 'js-yaml'; + +const useDrawerStyles = makeStyles((theme: Theme) => + createStyles({ + paper: { + width: '50%', + justifyContent: 'space-between', + padding: theme.spacing(2.5), + }, + }), +); + +const useDrawerContentStyles = makeStyles((_: Theme) => + createStyles({ + header: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + options: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + icon: { + fontSize: 20, + }, + content: { + height: '80%', + }, + }), +); + +const PodDrawerButton = withStyles({ + root: { + padding: '6px 5px', + }, + label: { + textTransform: 'none', + }, +})(Button); + +interface KubernetesDrawerable { + metadata?: V1ObjectMeta; +} + +interface KubernetesDrawerProps { + object: T; + renderObject: (obj: T) => object; + buttonVariant?: 'h5' | 'subtitle2'; + kind: string; + expanded?: boolean; + children?: React.ReactNode; +} + +export const KubernetesDrawer = ({ + object, + renderObject, + kind, + buttonVariant = 'subtitle2', + expanded = false, + children, +}: KubernetesDrawerProps) => { + const [isOpen, setIsOpen] = useState(expanded); + const classes = useDrawerStyles(); + + const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => { + e.stopPropagation(); + setIsOpen(newValue); + }; + + return ( + <> + toggleDrawer(e, true)} + onFocus={event => event.stopPropagation()} + > + {children === undefined ? ( + + {object.metadata?.name ?? 'unknown object'} + + ) : ( + children + )} + + toggleDrawer(e, false)} + onClick={event => event.stopPropagation()} + > + + + + ); +}; + +interface KubernetesDrawerContentProps { + toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void; + object: T; + renderObject: (obj: T) => object; + kind: string; +} + +const KubernetesDrawerContent = ({ + toggleDrawer, + object, + renderObject, + kind, +}: KubernetesDrawerContentProps) => { + const [isYaml, setIsYaml] = useState(false); + + const classes = useDrawerContentStyles(); + + return ( + <> +
+ + + + {object.metadata?.name ?? 'unknown name'} + + + + + {kind} + + + + toggleDrawer(e, false)} + color="inherit" + > + + +
+
+ { + setIsYaml(event.target.checked); + }} + name="YAML" + /> + } + label="YAML" + /> +
+
+ {isYaml && } + {!isYaml && } +
+ + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx new file mode 100644 index 0000000000..f166dc5f47 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { render } from '@testing-library/react'; +import * as pod from './__fixtures__/pod.json'; +import * as crashingPod from './__fixtures__/crashing-pod.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { PodDrawer } from './PodDrawer'; + +describe('PodDrawer', () => { + it('should render pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + expect(getAllByText('dice-roller-6c8646bfd-2m5hv')).toHaveLength(2); + expect(getByText('Pod')).toBeInTheDocument(); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Images')).toBeInTheDocument(); + expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('Phase')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getAllByText('Containers Ready')).toHaveLength(2); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('Total Restarts')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('Container Statuses')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getByText('Initialized')).toBeInTheDocument(); + expect(getByText('Ready')).toBeInTheDocument(); + expect(getByText('Pod Scheduled')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(4); + expect(getByText('Exposed Ports')).toBeInTheDocument(); + expect(getByText('Nginx:')).toBeInTheDocument(); + expect(getByText('Container Port: 80')).toBeInTheDocument(); + expect(getByText('Protocol: TCP')).toBeInTheDocument(); + }); + it('should render crashing pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + expect(getAllByText('dice-roller-canary-7d64cd756c-55rfq')).toHaveLength(2); + expect(getByText('Pod')).toBeInTheDocument(); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Images')).toBeInTheDocument(); + expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('other-side-car=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('side-car=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('Phase')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getAllByText('Containers Ready')).toHaveLength(2); + expect(getByText('1/3')).toBeInTheDocument(); + expect(getByText('Total Restarts')).toBeInTheDocument(); + expect(getByText('76')).toBeInTheDocument(); + expect(getByText('Container Statuses')).toBeInTheDocument(); + expect(getByText('Container: side-car')).toBeInTheDocument(); + expect(getByText('Container: other-side-car')).toBeInTheDocument(); + expect(getAllByText('CrashLoopBackOff')).toHaveLength(2); + expect(getByText('Initialized')).toBeInTheDocument(); + expect(getByText('Ready')).toBeInTheDocument(); + expect(getByText('Pod Scheduled')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(2); + expect(getAllByText('False')).toHaveLength(2); + expect( + getAllByText('containers with unready status: [side-car other-side-car]'), + ).toHaveLength(2); + expect(getByText('Exposed Ports')).toBeInTheDocument(); + expect(getAllByText('Protocol: TCP')).toHaveLength(3); + expect(getByText('Nginx:')).toBeInTheDocument(); + expect(getByText('Container Port: 80')).toBeInTheDocument(); + expect(getByText('Side Car:')).toBeInTheDocument(); + expect(getByText('Container Port: 81')).toBeInTheDocument(); + expect(getByText('Other Side Car:')).toBeInTheDocument(); + expect(getByText('Container Port: 82')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx new file mode 100644 index 0000000000..ed9e208fb1 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1Pod } from '@kubernetes/client-node'; +import { + containersReady, + containerStatuses, + totalRestarts, + imageChips, + renderCondition, +} from '../../utils/pod'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; + +export const PodDrawer = ({ + pod, + expanded, +}: { + pod: V1Pod; + expanded?: boolean; +}) => { + return ( + { + const phase = pod.status?.phase ?? 'unknown'; + + const ports = + pod.spec?.containers?.map(c => { + return { + [c.name]: c.ports, + }; + }) ?? 'N/A'; + + const conditions = (pod.status?.conditions ?? []) + .map(renderCondition) + .reduce((accum, next) => { + accum[next[0]] = next[1]; + return accum; + }, {} as { [key: string]: React.ReactNode }); + + return { + images: imageChips(pod), + phase: phase, + 'Containers Ready': containersReady(pod), + 'Total Restarts': totalRestarts(pod), + 'Container Statuses': containerStatuses(pod), + ...conditions, + 'Exposed ports': ports, + }; + }} + /> + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx new file mode 100644 index 0000000000..96f3035372 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { render } from '@testing-library/react'; +import * as pod from './__fixtures__/pod.json'; +import * as crashingPod from './__fixtures__/crashing-pod.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { PodsTable } from './PodsTable'; + +describe('PodsTable', () => { + it('should render pod', async () => { + const { getByText } = render( + wrapInTestApp(), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).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(); + }); + it('should render crashing pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + + // values + expect( + getByText('dice-roller-canary-7d64cd756c-55rfq'), + ).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/3')).toBeInTheDocument(); + expect(getByText('76')).toBeInTheDocument(); + expect(getByText('Container: side-car')).toBeInTheDocument(); + expect(getByText('Container: other-side-car')).toBeInTheDocument(); + expect(getAllByText('CrashLoopBackOff')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx new file mode 100644 index 0000000000..fb2b26cd39 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Table, TableColumn } from '@backstage/core'; +import { V1Pod } from '@kubernetes/client-node'; +import { PodDrawer } from './PodDrawer'; +import { + containersReady, + containerStatuses, + totalRestarts, +} from '../../utils/pod'; + +const columns: TableColumn[] = [ + { + title: 'name', + highlight: true, + render: (pod: V1Pod) => , + }, + { + title: 'phase', + render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', + }, + { + title: 'containers ready', + align: 'center', + render: containersReady, + }, + { + title: 'total restarts', + align: 'center', + render: totalRestarts, + type: 'numeric', + }, + { + title: 'status', + render: containerStatuses, + }, +]; + +type DeploymentTablesProps = { + pods: V1Pod[]; + children?: React.ReactNode; +}; + +export const PodsTable = ({ pods }: DeploymentTablesProps) => { + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + return ( +
+
+ + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json @@ -0,0 +1,233 @@ +{ + "metadata": { + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "name": "dice-roller-canary-7d64cd756c-55rfq", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620452", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq", + "uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:01.000Z" + } + } + }, + { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.16", + "podIPs": [ + { + "ip": "172.17.0.16" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:01.000Z" + } +} diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json @@ -0,0 +1,139 @@ +{ + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "name": "dice-roller-6c8646bfd-2m5hv", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593216", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv", + "uid": "aadb71c0-36fa-43e3-b38a-162f134d4359" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.11", + "podIPs": [ + { + "ip": "172.17.0.11" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } +} diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/kubernetes/src/components/Pods/index.ts similarity index 87% rename from plugins/kubernetes/src/components/Services/index.ts rename to plugins/kubernetes/src/components/Pods/index.ts index d52ebf5f14..319950b1da 100644 --- a/plugins/kubernetes/src/components/Services/index.ts +++ b/plugins/kubernetes/src/components/Pods/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Services } from './Services'; +export { PodDrawer } from './PodDrawer'; +export { PodsTable } from './PodsTable'; diff --git a/plugins/kubernetes/src/components/Services/Services.test.tsx b/plugins/kubernetes/src/components/Services/Services.test.tsx deleted file mode 100644 index 5e74499bc5..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { render } from '@testing-library/react'; -import { Services } from './Services'; -import * as servicesFixture from './__fixtures__/services.json'; -import { wrapInTestApp } from '@backstage/test-utils'; - -describe('Services', () => { - it('should render 2 services', async () => { - const { getByText, getAllByText } = render( - wrapInTestApp(), - ); - - // common elements - expect(getAllByText('Service')).toHaveLength(2); - expect(getAllByText('Ports')).toHaveLength(2); - expect(getAllByText('Type')).toHaveLength(2); - expect(getAllByText('Protocol: TCP')).toHaveLength(3); - - // service 1 - expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('ClusterIP')).toBeInTheDocument(); - - expect(getByText('Name: port1')).toBeInTheDocument(); - expect(getByText('Port: 80')).toBeInTheDocument(); - expect(getByText('Target Port: 9376')).toBeInTheDocument(); - expect(getByText('Name: port1')).toBeInTheDocument(); - expect(getByText('Port: 81')).toBeInTheDocument(); - expect(getByText('Target Port: 9377')).toBeInTheDocument(); - expect(getByText('10.102.223.105')).toBeInTheDocument(); - - // service 2 - expect(getByText('dice-roller-lb')).toBeInTheDocument(); - expect(getByText('LoadBalancer')).toBeInTheDocument(); - expect(getByText('Node Port: 32276')).toBeInTheDocument(); - expect(getByText('Port: 8765')).toBeInTheDocument(); - expect(getByText('Target Port: 9378')).toBeInTheDocument(); - }); -}); diff --git a/plugins/kubernetes/src/components/Services/Services.tsx b/plugins/kubernetes/src/components/Services/Services.tsx deleted file mode 100644 index d0a4f71eae..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Grid } from '@material-ui/core'; -import { V1Service } from '@kubernetes/client-node'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; - -type ServicesProps = { - services: V1Service[]; - children?: React.ReactNode; -}; - -export const Services = ({ services }: ServicesProps) => { - return ( - - {services.map((s, i) => { - const metadata: any = {}; - - if (s.status?.loadBalancer?.ingress?.length ?? -1 > 0) { - metadata.loadbalancer = s.status?.loadBalancer; - } - - if (s.spec?.type === 'ClusterIP') { - metadata.clusterIP = s.spec.clusterIP; - } - - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/Services/__fixtures__/services.json b/plugins/kubernetes/src/components/Services/__fixtures__/services.json deleted file mode 100644 index 7f9b49a9f6..0000000000 --- a/plugins/kubernetes/src/components/Services/__fixtures__/services.json +++ /dev/null @@ -1,164 +0,0 @@ -[ - { - "metadata": { - "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"name\":\"port1\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":9376},{\"name\":\"port2\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":9377}],\"selector\":{\"app\":\"dice-roller\"}}}\n" - }, - "creationTimestamp": "2020-09-23T12:00:55.000Z", - "labels": { - "backstage.io/kubernetes-id": "dice-roller" - }, - "managedFields": [ - { - "apiVersion": "v1", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:metadata": { - "f:annotations": { - ".": {}, - "f:kubectl.kubernetes.io/last-applied-configuration": {} - }, - "f:labels": { - ".": {}, - "f:backstage.io/kubernetes-id": {} - } - }, - "f:spec": { - "f:ports": { - ".": {}, - "k:{\"port\":80,\"protocol\":\"TCP\"}": { - ".": {}, - "f:name": {}, - "f:port": {}, - "f:protocol": {}, - "f:targetPort": {} - }, - "k:{\"port\":81,\"protocol\":\"TCP\"}": { - ".": {}, - "f:name": {}, - "f:port": {}, - "f:protocol": {}, - "f:targetPort": {} - } - }, - "f:selector": { - ".": {}, - "f:app": {} - }, - "f:sessionAffinity": {}, - "f:type": {} - } - }, - "manager": "kubectl", - "operation": "Update", - "time": "2020-09-28T08:50:11.000Z" - } - ], - "name": "dice-roller", - "namespace": "default", - "resourceVersion": "665838", - "selfLink": "/api/v1/namespaces/default/services/dice-roller", - "uid": "ae9aff92-a525-4bc9-82dc-a0537bf8034c" - }, - "spec": { - "clusterIP": "10.102.223.105", - "ports": [ - { - "name": "port1", - "port": 80, - "protocol": "TCP", - "targetPort": 9376 - }, - { - "name": "port2", - "port": 81, - "protocol": "TCP", - "targetPort": 9377 - } - ], - "selector": { - "app": "dice-roller" - }, - "sessionAffinity": "None", - "type": "ClusterIP" - }, - "status": { - "loadBalancer": {} - } - }, - { - "metadata": { - "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-lb\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"port\":8765,\"targetPort\":9376}],\"selector\":{\"app\":\"dice-roller\"},\"type\":\"LoadBalancer\"}}\n" - }, - "creationTimestamp": "2020-09-28T08:51:21.000Z", - "labels": { - "backstage.io/kubernetes-id": "dice-roller" - }, - "managedFields": [ - { - "apiVersion": "v1", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:metadata": { - "f:annotations": { - ".": {}, - "f:kubectl.kubernetes.io/last-applied-configuration": {} - }, - "f:labels": { - ".": {}, - "f:backstage.io/kubernetes-id": {} - } - }, - "f:spec": { - "f:externalTrafficPolicy": {}, - "f:ports": { - ".": {}, - "k:{\"port\":8765,\"protocol\":\"TCP\"}": { - ".": {}, - "f:port": {}, - "f:protocol": {}, - "f:targetPort": {} - } - }, - "f:selector": { - ".": {}, - "f:app": {} - }, - "f:sessionAffinity": {}, - "f:type": {} - } - }, - "manager": "kubectl", - "operation": "Update", - "time": "2020-09-28T08:51:21.000Z" - } - ], - "name": "dice-roller-lb", - "namespace": "default", - "resourceVersion": "665998", - "selfLink": "/api/v1/namespaces/default/services/dice-roller-lb", - "uid": "5554da3b-2041-4403-8cf4-cd2ccae760f8" - }, - "spec": { - "clusterIP": "10.99.205.233", - "externalTrafficPolicy": "Cluster", - "ports": [ - { - "nodePort": 32276, - "port": 8765, - "protocol": "TCP", - "targetPort": 9378 - } - ], - "selector": { - "app": "dice-roller" - }, - "sessionAffinity": "None", - "type": "LoadBalancer" - }, - "status": { - "loadBalancer": {} - } - } -] diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json new file mode 100644 index 0000000000..f07a6f7d69 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json @@ -0,0 +1,119 @@ +{ + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "3", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-canary\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller-canary\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller-canary\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]},{\"image\":\"nginx:1.14.2\",\"name\":\"side-car\",\"ports\":[{\"containerPort\":81}]},{\"image\":\"nginx:1.14.2\",\"name\":\"other-side-car\",\"ports\":[{\"containerPort\":82}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-25T09:02:53.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "name": "dice-roller-canary", + "namespace": "default", + "resourceVersion": "620480", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 2, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller-canary" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "conditions": [ + { + "lastTransitionTime": "2020-09-25T09:02:53.000Z", + "lastUpdateTime": "2020-09-25T10:34:04.000Z", + "message": "ReplicaSet \"dice-roller-canary-7d64cd756c\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T13:48:06.000Z", + "lastUpdateTime": "2020-09-25T13:48:06.000Z", + "message": "Deployment does not have minimum availability.", + "reason": "MinimumReplicasUnavailable", + "status": "False", + "type": "Available" + } + ], + "observedGeneration": 3, + "replicas": 2, + "unavailableReplicas": 2, + "updatedReplicas": 2 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json new file mode 100644 index 0000000000..87a7e07b0d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json @@ -0,0 +1,92 @@ +{ + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "2", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"replicas\":10,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-23T12:00:55.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "593230", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller", + "uid": "7551e949-42d1-4061-83c5-9da107186e47" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 10, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 10, + "conditions": [ + { + "lastTransitionTime": "2020-09-23T12:00:55.000Z", + "lastUpdateTime": "2020-09-24T11:39:28.000Z", + "message": "ReplicaSet \"dice-roller-6c8646bfd\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "lastUpdateTime": "2020-09-25T09:58:55.000Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + } + ], + "observedGeneration": 3, + "readyReplicas": 10, + "replicas": 10, + "updatedReplicas": 10 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json new file mode 100644 index 0000000000..23edee5a07 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json @@ -0,0 +1,32 @@ +{ + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "698957", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446" + }, + "spec": { + "maxReplicas": 15, + "minReplicas": 10, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 50 + }, + "status": { + "currentReplicas": 13, + "desiredReplicas": 14, + "currentCPUUtilizationPercentage": 30 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json new file mode 100644 index 0000000000..4466e7b4b1 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json @@ -0,0 +1,32 @@ +{ + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "698957", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446" + }, + "spec": { + "maxReplicas": 10, + "minReplicas": 5, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 70 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 10, + "currentCPUUtilizationPercentage": 100 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json @@ -0,0 +1,233 @@ +{ + "metadata": { + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "name": "dice-roller-canary-7d64cd756c-55rfq", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620452", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq", + "uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:01.000Z" + } + } + }, + { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.16", + "podIPs": [ + { + "ip": "172.17.0.16" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:01.000Z" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json new file mode 100644 index 0000000000..3fdd4deaab --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json @@ -0,0 +1,168 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "creationTimestamp": "2021-01-06T14:33:54Z", + "generateName": "dice-roller-bad-cm-855bf85464-", + "labels": { + "app": "dice-roller-bad-cm", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "855bf85464" + }, + "name": "dice-roller-bad-cm-855bf85464-mg6xb", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-bad-cm-855bf85464", + "uid": "9bc2a418-60eb-4dfc-9748-78cf49ea9863" + } + ], + "resourceVersion": "2457755284", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-bad-cm-855bf85464-mg6xb", + "uid": "5f257d3c-a16d-4ef1-9dc9-d11e321a640a" + }, + "spec": { + "containers": [ + { + "env": [ + { + "name": "SOME_ENV_VAR", + "valueFrom": { + "configMapKeyRef": { + "key": "some-key", + "name": "some-cm" + } + } + } + ], + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": { + "limits": { + "cpu": "500m", + "memory": "128Mi" + }, + "requests": { + "cpu": "50m", + "memory": "64Mi" + } + }, + "securityContext": { + "allowPrivilegeEscalation": false + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-dbkkb", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "node1", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-dbkkb", + "secret": { + "defaultMode": 420, + "secretName": "default-token-dbkkb" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "message": "containers with unready status: [nginx]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "message": "containers with unready status: [nginx]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "image": "nginx:1.14.2", + "imageID": "", + "lastState": {}, + "name": "nginx", + "ready": false, + "restartCount": 0, + "started": false, + "state": { + "waiting": { + "message": "configmap \"some-cm\" not found", + "reason": "CreateContainerConfigError" + } + } + } + ], + "hostIP": "10.1.33.73", + "phase": "Pending", + "podIP": "10.1.115.15", + "podIPs": [ + { + "ip": "10.1.115.15" + } + ], + "qosClass": "Burstable", + "startTime": "2021-01-06T14:33:54Z" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json @@ -0,0 +1,139 @@ +{ + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "name": "dice-roller-6c8646bfd-2m5hv", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593216", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv", + "uid": "aadb71c0-36fa-43e3-b38a-162f134d4359" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.11", + "podIPs": [ + { + "ip": "172.17.0.11" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } +} diff --git a/plugins/kubernetes/src/error-detection/common.ts b/plugins/kubernetes/src/error-detection/common.ts new file mode 100644 index 0000000000..626eaf39d4 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/common.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + DetectedError, + ErrorDetectable, + ErrorDetectableKind, + ErrorMapper, +} from './types'; + +// Run through the each error mapper for each object +// returning a deduplicated (mostly) result +export const detectErrorsInObjects = ( + objects: T[], + kind: ErrorDetectableKind, + clusterName: string, + errorMappers: ErrorMapper[], +): DetectedError[] => { + // Build up a map of errors + // key: the joined message produced by an error + // value: the error + const errors = new Map(); + + for (const object of objects) { + for (const errorMapper of errorMappers) { + if (errorMapper.errorExists(object)) { + const message = errorMapper.messageAccessor(object); + + // TODO This is not perfect as errors with uuid/hashes/date/times will not be caught by this + const dedupKey = message.join(''); + + const value = errors.get(dedupKey); + + const name = object.metadata?.name ?? 'unknown'; + + if (value !== undefined) { + // This gets translated into the Chip "+5 others" + // in the ErrorReporting component + // but we need to keep the names so we can easily + // find which objects owns the error later + value.names.push(name); + errors.set(dedupKey, value); + } else { + errors.set(dedupKey, { + cluster: clusterName, + kind: kind, + names: [name], + message: message, + severity: errorMapper.severity, + }); + } + } + } + } + + return Array.from(errors.values()); +}; diff --git a/plugins/kubernetes/src/error-detection/deployments.ts b/plugins/kubernetes/src/error-detection/deployments.ts new file mode 100644 index 0000000000..f70d4196cb --- /dev/null +++ b/plugins/kubernetes/src/error-detection/deployments.ts @@ -0,0 +1,49 @@ +import { DetectedError, ErrorMapper } from './types'; +import { V1Deployment } from '@kubernetes/client-node'; +import { detectErrorsInObjects } from './common'; + +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +const deploymentErrorMappers: ErrorMapper[] = [ + { + // this is probably important + severity: 6, + errorExplanation: 'condition-message-present', + errorExists: deployment => { + return (deployment.status?.conditions ?? []) + .filter(c => c.status === 'False') + .some(c => c.message !== undefined); + }, + messageAccessor: deployment => { + return (deployment.status?.conditions ?? []) + .filter(c => c.status === 'False') + .filter(c => c.message !== undefined) + .map(c => c.message ?? ''); + }, + }, +]; + +export const detectErrorsInDeployments = ( + deployments: V1Deployment[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects( + deployments, + 'Deployment', + clusterName, + deploymentErrorMappers, + ); diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts new file mode 100644 index 0000000000..8a4575c8be --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -0,0 +1,282 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + V1Pod, + V1Deployment, + V1HorizontalPodAutoscaler, +} from '@kubernetes/client-node'; +import { detectErrors } from './error-detection'; +import * as healthyPod from './__fixtures__/pod.json'; +import * as podMissingCm from './__fixtures__/pod-missing-cm.json'; +import * as crashingPod from './__fixtures__/pod-crashing.json'; +import * as healthyDeploy from './__fixtures__/deploy-healthy.json'; +import * as failingDeploy from './__fixtures__/deploy-bad.json'; +import * as healthyHpa from './__fixtures__/hpa-healthy.json'; +import * as maxedOutHpa from './__fixtures__/hpa-maxed-out.json'; +import { + FetchResponse, + ObjectsByEntityResponse, +} from '@backstage/plugin-kubernetes-backend'; + +const CLUSTER_NAME = 'cluster-a'; + +const oneItem = (value: FetchResponse): ObjectsByEntityResponse => { + return { + items: [ + { + cluster: { name: CLUSTER_NAME }, + errors: [], + resources: [value], + }, + ], + }; +}; + +const onePod = (pod: V1Pod): ObjectsByEntityResponse => { + return oneItem({ + type: 'pods', + resources: [pod], + }); +}; + +const oneDeployment = (deployment: V1Deployment): ObjectsByEntityResponse => { + return oneItem({ + type: 'deployments', + resources: [deployment], + }); +}; + +const oneHpa = (hpa: V1HorizontalPodAutoscaler): ObjectsByEntityResponse => { + return oneItem({ + type: 'horizontalpodautoscalers', + resources: [hpa], + }); +}; + +describe('detectErrors', () => { + it('should return errors from different clusters', () => { + const result = detectErrors({ + items: [ + { + cluster: { name: 'cluster-a' }, + errors: [], + resources: [ + { + type: 'pods', + resources: [crashingPod as any], + }, + ], + }, + { + cluster: { name: 'cluster-b' }, + errors: [], + resources: [ + { + type: 'horizontalpodautoscalers', + resources: [maxedOutHpa as any], + }, + ], + }, + { + cluster: { name: 'cluster-c' }, + errors: [], + resources: [ + { + type: 'deployments', + resources: [healthyDeploy as any], + }, + ], + }, + ], + }); + + expect(result.size).toBe(3); + + const errorsFromClusterA = result.get('cluster-a'); + const errorsFromClusterB = result.get('cluster-b'); + const errorsFromClusterC = result.get('cluster-c'); + + expect(errorsFromClusterA).toBeDefined(); + expect(errorsFromClusterA).toHaveLength(4); + + expect(errorsFromClusterB).toBeDefined(); + expect(errorsFromClusterB).toHaveLength(1); + + expect(errorsFromClusterC).toBeDefined(); + expect(errorsFromClusterC).toHaveLength(0); + }); + + it('should detect no errors in healthy pod', () => { + const result = detectErrors(onePod(healthyPod as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect errors in crashing pod', () => { + const result = detectErrors(onePod(crashingPod as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(4); + + const [err1, err2, err3, err4] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'container=other-side-car restarted 38 times', + 'container=side-car restarted 38 times', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 4, + }); + + expect(err2).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'containers with unready status: [side-car other-side-car]', + 'containers with unready status: [side-car other-side-car]', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 5, + }); + + expect(err3).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)', + 'back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 6, + }); + + expect(err4).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'container=other-side-car exited with error code (1)', + 'container=side-car exited with error code (1)', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 4, + }); + }); + it('should detect errors in pod with missing Config Map', () => { + const result = detectErrors(onePod(podMissingCm as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(2); + + const [err1, err2] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'containers with unready status: [nginx]', + 'containers with unready status: [nginx]', + ], + names: ['dice-roller-bad-cm-855bf85464-mg6xb'], + severity: 5, + }); + + expect(err2).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: ['configmap "some-cm" not found'], + names: ['dice-roller-bad-cm-855bf85464-mg6xb'], + severity: 6, + }); + }); + it('should detect no errors in healthy deployment', () => { + const result = detectErrors(oneDeployment(healthyDeploy as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect in deployment which cant progress', () => { + const result = detectErrors(oneDeployment(failingDeploy as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + + const [err1] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Deployment', + message: ['Deployment does not have minimum availability.'], + names: ['dice-roller-canary'], + severity: 6, + }); + }); + it('should detect no errors in healthy hpa', () => { + const result = detectErrors(oneHpa(healthyHpa as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect in maxed out hpa', () => { + const result = detectErrors(oneHpa(maxedOutHpa as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + + const [err1] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'HorizontalPodAutoscaler', + message: [ + 'Current number of replicas (10) is equal to the configured max number of replicas (10)', + ], + names: ['dice-roller'], + severity: 8, + }); + }); +}); diff --git a/plugins/kubernetes/src/error-detection/error-detection.ts b/plugins/kubernetes/src/error-detection/error-detection.ts new file mode 100644 index 0000000000..a44747969d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { DetectedError, DetectedErrorsByCluster } from './types'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-backend'; +import { groupResponses } from '../utils/response'; +import { detectErrorsInPods } from './pods'; +import { detectErrorsInDeployments } from './deployments'; +import { detectErrorsInHpa } from './hpas'; + +// For each cluster try to find errors in each of the object types provided +// returning a map of cluster names to errors in that cluster +export const detectErrors = ( + objects: ObjectsByEntityResponse, +): DetectedErrorsByCluster => { + const errors: DetectedErrorsByCluster = new Map(); + + for (const clusterResponse of objects.items) { + let clusterErrors: DetectedError[] = []; + + const groupedResponses = groupResponses(clusterResponse.resources); + + clusterErrors = clusterErrors.concat( + detectErrorsInPods(groupedResponses.pods, clusterResponse.cluster.name), + ); + + clusterErrors = clusterErrors.concat( + detectErrorsInDeployments( + groupedResponses.deployments, + clusterResponse.cluster.name, + ), + ); + + clusterErrors = clusterErrors.concat( + detectErrorsInHpa( + groupedResponses.horizontalPodAutoscalers, + clusterResponse.cluster.name, + ), + ); + + errors.set(clusterResponse.cluster.name, clusterErrors); + } + + return errors; +}; diff --git a/plugins/kubernetes/src/error-detection/hpas.ts b/plugins/kubernetes/src/error-detection/hpas.ts new file mode 100644 index 0000000000..53c429b2f2 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/hpas.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { DetectedError, ErrorMapper } from './types'; +import { detectErrorsInObjects } from './common'; + +const hpaErrorMappers: ErrorMapper[] = [ + { + // this is probably important + severity: 8, + errorExplanation: 'hpa-max-current-replicas', + errorExists: hpa => { + return (hpa.spec?.maxReplicas ?? -1) === hpa.status?.currentReplicas; + }, + messageAccessor: hpa => { + return [ + `Current number of replicas (${ + hpa.status?.currentReplicas + }) is equal to the configured max number of replicas (${ + hpa.spec?.maxReplicas ?? -1 + })`, + ]; + }, + }, +]; + +export const detectErrorsInHpa = ( + hpas: V1HorizontalPodAutoscaler[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects( + hpas, + 'HorizontalPodAutoscaler', + clusterName, + hpaErrorMappers, + ); diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/plugins/kubernetes/src/error-detection/index.ts similarity index 82% rename from plugins/kubernetes/src/components/ConfigMaps/index.ts rename to plugins/kubernetes/src/error-detection/index.ts index b1c7d37a5a..69a4d11851 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/index.ts +++ b/plugins/kubernetes/src/error-detection/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ConfigMaps } from './ConfigMaps'; + +export type { DetectedError, DetectedErrorsByCluster } from './types'; +export { detectErrors } from './error-detection'; diff --git a/plugins/kubernetes/src/error-detection/pods.ts b/plugins/kubernetes/src/error-detection/pods.ts new file mode 100644 index 0000000000..8b990d0d52 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/pods.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1Pod } from '@kubernetes/client-node'; +import { totalRestarts } from '../utils/pod'; +import { DetectedError, ErrorMapper } from './types'; +import { detectErrorsInObjects } from './common'; + +const podErrorMappers: ErrorMapper[] = [ + { + severity: 5, + errorExplanation: 'status-message', + errorExists: pod => { + return pod.status?.message !== undefined; + }, + messageAccessor: pod => { + return [pod.status?.message ?? '']; + }, + }, + { + severity: 4, + errorExplanation: 'containers-restarting', + errorExists: pod => { + // TODO magic number + return totalRestarts(pod) > 3; + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => cs.restartCount > 0) + .map(cs => `container=${cs.name} restarted ${cs.restartCount} times`); + }, + }, + { + severity: 5, + errorExplanation: 'condition-message-present', + errorExists: pod => { + return (pod.status?.conditions ?? []).some(c => c.message !== undefined); + }, + messageAccessor: pod => { + return (pod.status?.conditions ?? []) + .filter(c => c.message !== undefined) + .map(c => c.message ?? ''); + }, + }, + { + severity: 6, + errorExplanation: 'container-waiting', + errorExists: pod => { + return (pod.status?.containerStatuses ?? []).some( + cs => cs.state?.waiting?.message !== undefined, + ); + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => cs.state?.waiting?.message !== undefined) + .map(cs => cs.state?.waiting?.message ?? ''); + }, + }, + { + severity: 4, + errorExplanation: 'container-last-state-error', + errorExists: pod => { + return (pod.status?.containerStatuses ?? []).some( + cs => (cs.lastState?.terminated?.reason ?? '') === 'Error', + ); + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => (cs.lastState?.terminated?.reason ?? '') === 'Error') + .map( + cs => + `container=${cs.name} exited with error code (${cs.lastState?.terminated?.exitCode})`, + ); + }, + }, +]; + +export const detectErrorsInPods = ( + pods: V1Pod[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects(pods, 'Pod', clusterName, podErrorMappers); diff --git a/plugins/kubernetes/src/error-detection/types.ts b/plugins/kubernetes/src/error-detection/types.ts new file mode 100644 index 0000000000..817911ce40 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +// Higher is more sever, but it's relative +import { + V1Deployment, + V1HorizontalPodAutoscaler, + V1Pod, +} from '@kubernetes/client-node'; + +export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + +export type ErrorDetectable = V1Pod | V1Deployment | V1HorizontalPodAutoscaler; + +export type ErrorDetectableKind = + | 'Pod' + | 'Deployment' + | 'HorizontalPodAutoscaler'; + +export type DetectedErrorsByCluster = Map; + +export interface DetectedError { + severity: ErrorSeverity; + cluster: string; + kind: ErrorDetectableKind; + names: string[]; + message: string[]; +} + +export interface ErrorMapper { + severity: ErrorSeverity; + errorExplanation: string; + errorExists: (object: T) => boolean; + messageAccessor: (object: T) => string[]; +} diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 09bff511a7..ab12730f72 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -14,10 +14,25 @@ * limitations under the License. */ -import { V1Deployment, V1Pod, V1ReplicaSet } from '@kubernetes/client-node'; +import { + V1Deployment, + V1Pod, + V1ReplicaSet, + V1HorizontalPodAutoscaler, + V1Service, + V1ConfigMap, + ExtensionsV1beta1Ingress, +} from '@kubernetes/client-node'; -export interface DeploymentTriple { +export interface DeploymentResources { pods: V1Pod[]; replicaSets: V1ReplicaSet[]; deployments: V1Deployment[]; + horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; +} + +export interface GroupedResponses extends DeploymentResources { + services: V1Service[]; + configMaps: V1ConfigMap[]; + ingresses: ExtensionsV1beta1Ingress[]; } diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx new file mode 100644 index 0000000000..4e317cd606 --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { V1Pod, V1PodCondition } from '@kubernetes/client-node'; +import React, { Fragment, ReactNode } from 'react'; +import { + StatusAborted, + StatusError, + StatusOK, + SubvalueCell, +} from '@backstage/core'; +import { Chip } from '@material-ui/core'; +import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition'; + +export const imageChips = (pod: V1Pod): ReactNode => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const images = containerStatuses.map((cs, i) => { + return ; + }); + + return
{images}
; +}; + +export const containersReady = (pod: V1Pod): string => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const containersReady = containerStatuses.filter(cs => cs.ready).length; + + return `${containersReady}/${containerStatuses.length}`; +}; + +export const totalRestarts = (pod: V1Pod): number => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses?.reduce((a, b) => a + b.restartCount, 0); +}; + +export const containerStatuses = (pod: V1Pod): ReactNode => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const errors = containerStatuses.reduce((accum, next) => { + if (next.state === undefined) { + return accum; + } + + const waiting = next.state.waiting; + const terminated = next.state.terminated; + + const renderCell = (reason: string | undefined) => ( + + Container: {next.name}} + subvalue={reason} + /> +
+
+ ); + + if (waiting) { + accum.push(renderCell(waiting.reason)); + } + + if (terminated) { + accum.push(renderCell(terminated.reason)); + } + + return accum; + }, [] as React.ReactNode[]); + + if (errors.length === 0) { + return OK; + } + + return errors; +}; + +export const renderCondition = ( + condition: V1PodCondition | V1DeploymentCondition, +): [string, ReactNode] => { + const status = condition.status; + + if (status === 'True') { + return [condition.type, True]; + } else if (status === 'False') { + return [ + condition.type, + False} + subvalue={condition.message ?? ''} + />, + ]; + } + return [condition.type, ]; +}; diff --git a/plugins/kubernetes/src/utils/response.ts b/plugins/kubernetes/src/utils/response.ts new file mode 100644 index 0000000000..54b319a6ef --- /dev/null +++ b/plugins/kubernetes/src/utils/response.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { FetchResponse } from '@backstage/plugin-kubernetes-backend'; +import { GroupedResponses } from '../types/types'; + +// TODO this could probably be a lodash groupBy +export const groupResponses = ( + fetchResponse: FetchResponse[], +): GroupedResponses => { + return fetchResponse.reduce( + (prev, next) => { + switch (next.type) { + case 'deployments': + prev.deployments.push(...next.resources); + break; + case 'pods': + prev.pods.push(...next.resources); + break; + case 'replicasets': + prev.replicaSets.push(...next.resources); + break; + case 'services': + prev.services.push(...next.resources); + break; + case 'configmaps': + prev.configMaps.push(...next.resources); + break; + case 'horizontalpodautoscalers': + prev.horizontalPodAutoscalers.push(...next.resources); + break; + case 'ingresses': + prev.ingresses.push(...next.resources); + break; + default: + } + return prev; + }, + { + pods: [], + replicaSets: [], + deployments: [], + services: [], + configMaps: [], + horizontalPodAutoscalers: [], + ingresses: [], + } as GroupedResponses, + ); +};