feat: Proposed fix dialog (#17576)

* feat: add errors to Pod Drawer

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

* fix: lint fix

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

* test: useMatchingErrorTest

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

* feat: pod fix dialog for errors

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

* fix: link

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

* fix: icon tweak

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

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-06-05 16:45:48 -04:00
committed by GitHub
parent 591d08b685
commit 73cc0deee4
33 changed files with 1457 additions and 73 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes': patch
---
Add proposed fix dialog for pod errors
@@ -32,6 +32,42 @@ spec:
ports:
- containerPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dice-roller-incorrect-probe
labels:
'backstage.io/kubernetes-id': dice-roller
spec:
selector:
matchLabels:
app: dice-roller-incorrect-probe
replicas: 2
template:
metadata:
labels:
app: dice-roller-incorrect-probe
'backstage.io/kubernetes-id': dice-roller
spec:
containers:
- name: nginx
image: nginx:1.14.2
resources:
requests:
memory: '64Mi'
cpu: '50m'
limits:
memory: '128Mi'
cpu: '50m'
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
ports:
- containerPort: 80
---
apiVersion: apps/v1
kind: Deployment
+190 -5
View File
@@ -6,6 +6,7 @@
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsyncFn';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
import { ClusterAttributes } from '@backstage/plugin-kubernetes-common';
@@ -14,6 +15,9 @@ import { CustomObjectsByEntityRequest } from '@backstage/plugin-kubernetes-commo
import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Event as Event_2 } from 'kubernetes-models/v1';
import { IContainer } from 'kubernetes-models/v1';
import { IContainerStatus } from 'kubernetes-models/v1';
import { IdentityApi } from '@backstage/core-plugin-api';
import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
import type { JsonObject } from '@backstage/types';
@@ -21,8 +25,8 @@ import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { OAuthApi } from '@backstage/core-plugin-api';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { Pod } from 'kubernetes-models/v1';
import { Pod as Pod_2 } from 'kubernetes-models/v1/Pod';
import { Pod } from 'kubernetes-models/v1/Pod';
import { Pod as Pod_2 } from 'kubernetes-models/v1';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { V1ConfigMap } from '@kubernetes/client-node';
@@ -78,6 +82,25 @@ export interface ClusterLinksFormatterOptions {
// @public (undocumented)
export const clusterLinksFormatters: Record<string, ClusterLinksFormatter>;
// @public
export const ContainerCard: React_2.FC<ContainerCardProps>;
// @public
export interface ContainerCardProps {
// (undocumented)
containerSpec?: IContainer;
// (undocumented)
containerStatus: IContainerStatus;
// (undocumented)
podScope: PodScope;
}
// @public
export interface ContainerScope extends PodScope {
// (undocumented)
containerName: string;
}
// Warning: (ae-forgotten-export) The symbol "CronJobsAccordionsProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "CronJobsAccordions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -113,7 +136,7 @@ export interface DetectedError {
// Warning: (ae-forgotten-export) The symbol "ProposedFix" needs to be exported by the entry point index.d.ts
//
// (undocumented)
proposedFix: ProposedFix[];
proposedFix?: ProposedFix;
// (undocumented)
severity: ErrorSeverity;
// Warning: (ae-forgotten-export) The symbol "ResourceRef" needs to be exported by the entry point index.d.ts
@@ -144,6 +167,15 @@ export type EntityKubernetesContentProps = {
refreshIntervalMs?: number;
};
// @public
export const ErrorList: ({ podAndErrors }: ErrorListProps) => JSX.Element;
// @public
export interface ErrorListProps {
// (undocumented)
podAndErrors: PodAndErrors[];
}
// Warning: (ae-forgotten-export) The symbol "ErrorPanelProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ErrorPanel" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -165,6 +197,65 @@ export const ErrorReporting: ({
// @public
export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
// @public
export const Events: ({
involvedObjectName,
namespace,
clusterName,
warningEventsOnly,
}: EventsProps) => JSX.Element;
// @public
export const EventsContent: ({
events,
warningEventsOnly,
}: EventsContentProps) => JSX.Element;
// @public
export interface EventsContentProps {
// (undocumented)
events: Event_2[];
// (undocumented)
warningEventsOnly?: boolean;
}
// @public
export interface EventsOptions {
// (undocumented)
clusterName: string;
// (undocumented)
involvedObjectName: string;
// (undocumented)
namespace: string;
}
// @public
export interface EventsProps {
// (undocumented)
clusterName: string;
// (undocumented)
involvedObjectName: string;
// (undocumented)
namespace: string;
// (undocumented)
warningEventsOnly?: boolean;
}
// @public
export const FixDialog: React_2.FC<FixDialogProps>;
// @public
export interface FixDialogProps {
// (undocumented)
clusterName: string;
// (undocumented)
error: DetectedError;
// (undocumented)
open?: boolean;
// (undocumented)
pod: Pod;
}
// Warning: (ae-forgotten-export) The symbol "FormatClusterLinkOptions" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "formatClusterLink" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -416,12 +507,19 @@ export { kubernetesPlugin as plugin };
//
// @public (undocumented)
export interface KubernetesProxyApi {
// (undocumented)
getEventsByInvolvedObjectName(request: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event_2[]>;
// (undocumented)
getPodLogs(request: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{
text: string;
}>;
@@ -436,16 +534,28 @@ export const kubernetesProxyApiRef: ApiRef<KubernetesProxyApi>;
export class KubernetesProxyClient {
constructor(options: { kubernetesApi: KubernetesApi });
// (undocumented)
getEventsByInvolvedObjectName({
clusterName,
involvedObjectName,
namespace,
}: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event_2[]>;
// (undocumented)
getPodLogs({
podName,
namespace,
clusterName,
containerName,
previous,
}: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{
text: string;
}>;
@@ -476,12 +586,62 @@ export const LinkErrorPanel: ({
errorMessage,
}: ErrorPanelProps_2) => JSX.Element;
// @public
export const PendingPodContent: ({
pod,
}: PendingPodContentProps) => JSX.Element;
// @public
export interface PendingPodContentProps {
// (undocumented)
pod: Pod_2;
}
// @public
export interface PodAndErrors {
// (undocumented)
clusterName: string;
// (undocumented)
errors: DetectedError[];
// (undocumented)
pod: Pod_2;
}
// Warning: (ae-forgotten-export) The symbol "PodDrawerProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "PodDrawer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const PodDrawer: ({ podAndErrors, open }: PodDrawerProps) => JSX.Element;
// @public
export const PodLogs: React_2.FC<PodLogsProps>;
// @public
export const PodLogsDialog: ({
containerScope,
}: PodLogsDialogProps) => JSX.Element;
// @public
export interface PodLogsDialogProps {
// (undocumented)
containerScope: ContainerScope;
}
// @public
export interface PodLogsOptions {
// (undocumented)
containerScope: ContainerScope;
// (undocumented)
previous?: boolean;
}
// @public
export interface PodLogsProps {
// (undocumented)
containerScope: ContainerScope;
// (undocumented)
previous?: boolean;
}
// Warning: (ae-missing-release-tag) "PodNamesWithErrorsContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -494,6 +654,16 @@ export const PodNamesWithMetricsContext: React_2.Context<
Map<string, ClientPodStatus>
>;
// @public
export interface PodScope {
// (undocumented)
clusterName: string;
// (undocumented)
podName: string;
// (undocumented)
podNamespace: string;
}
// Warning: (ae-forgotten-export) The symbol "PodsTablesProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "PodsTable" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -533,6 +703,13 @@ export const useCustomResources: (
intervalMs?: number,
) => KubernetesObjects;
// @public
export const useEvents: ({
involvedObjectName,
namespace,
clusterName,
}: EventsOptions) => AsyncState<Event_2[]>;
// Warning: (ae-missing-release-tag) "useKubernetesObjects" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -540,4 +717,12 @@ export const useKubernetesObjects: (
entity: Entity,
intervalMs?: number,
) => KubernetesObjects;
// @public
export const usePodLogs: ({
containerScope,
previous,
}: PodLogsOptions) => AsyncState<{
text: string;
}>;
```
@@ -14,11 +14,13 @@
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { KubernetesProxyClient } from './KubernetesProxyClient';
describe('KubernetesProxyClient', () => {
let proxy: KubernetesProxyClient;
const callProxyMock = jest.fn();
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
beforeEach(() => {
jest.resetAllMocks();
@@ -51,4 +53,72 @@ describe('KubernetesProxyClient', () => {
path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container',
});
});
it('/logs returns log text - crash logs', async () => {
const request = {
podName: 'some-pod',
namespace: 'some-namespace',
clusterName: 'some-cluster',
containerName: 'some-container',
previous: true,
};
callProxyMock.mockResolvedValue({
text: jest.fn().mockResolvedValue('Hello World'),
ok: true,
});
const response = await proxy.getPodLogs(request);
await expect(response).toStrictEqual({ text: 'Hello World' });
expect(callProxyMock).toHaveBeenCalledWith({
clusterName: 'some-cluster',
init: {
method: 'GET',
},
path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container&previous=',
});
});
it('/getEventsByInvolvedObjectName returns events', async () => {
const request = {
clusterName: 'some-cluster',
involvedObjectName: 'some-object',
namespace: 'some-namespace',
};
const events = [
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
},
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
];
callProxyMock.mockResolvedValue({
json: jest.fn().mockResolvedValue({
items: events,
}),
ok: true,
});
const response = await proxy.getEventsByInvolvedObjectName(request);
await expect(response).toStrictEqual(events);
expect(callProxyMock).toHaveBeenCalledWith({
clusterName: 'some-cluster',
init: {
method: 'GET',
},
path: '/api/v1/namespaces/some-namespace/events?fieldSelector=involvedObject.name=some-object',
});
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { KubernetesApi } from './types';
import { Event } from 'kubernetes-models/v1';
/**
* A client for common requests through the proxy endpoint of the kubernetes backend plugin.
@@ -41,20 +42,63 @@ export class KubernetesProxyClient {
return await response.text();
}
private async handleJson(response: Response): Promise<any> {
if (!response.ok) {
const payload = await response.text();
let message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
switch (response.status) {
case 404:
message = `Proxy request failed with ${response.status} ${response.statusText}, ${payload}`;
break;
default:
message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
return await response.json();
}
async getEventsByInvolvedObjectName({
clusterName,
involvedObjectName,
namespace,
}: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event[]> {
return await this.kubernetesApi
.proxy({
clusterName,
path: `/api/v1/namespaces/${namespace}/events?fieldSelector=involvedObject.name=${involvedObjectName}`,
init: {
method: 'GET',
},
})
.then(response => this.handleJson(response))
.then(eventList => eventList.items);
}
async getPodLogs({
podName,
namespace,
clusterName,
containerName,
previous,
}: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{ text: string }> {
const params = new URLSearchParams({
container: containerName,
});
if (previous) {
params.append('previous', '');
}
return await this.kubernetesApi
.proxy({
clusterName: clusterName,
+7
View File
@@ -21,6 +21,7 @@ import {
CustomObjectsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { createApiRef } from '@backstage/core-plugin-api';
import { Event } from 'kubernetes-models/v1';
export const kubernetesApiRef = createApiRef<KubernetesApi>({
id: 'plugin.kubernetes.service',
@@ -60,5 +61,11 @@ export interface KubernetesProxyApi {
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{ text: string }>;
getEventsByInvolvedObjectName(request: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event[]>;
}
@@ -45,15 +45,13 @@ describe('ErrorList', () => {
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
},
],
proposedFix: {
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
},
],
},
@@ -24,8 +24,10 @@ import {
makeStyles,
Theme,
Paper,
Grid,
} from '@material-ui/core';
import { PodAndErrors } from '../types';
import { FixDialog } from '../FixDialog/FixDialog';
const useStyles = makeStyles((_theme: Theme) =>
createStyles({
@@ -38,10 +40,20 @@ const useStyles = makeStyles((_theme: Theme) =>
}),
);
interface ErrorListProps {
/**
* Props for ErrorList
*
* @public
*/
export interface ErrorListProps {
podAndErrors: PodAndErrors[];
}
/**
* Shows a list of errors found on a Pod
*
* @public
*/
export const ErrorList = ({ podAndErrors }: ErrorListProps) => {
const classes = useStyles();
return (
@@ -59,10 +71,21 @@ export const ErrorList = ({ podAndErrors }: ErrorListProps) => {
>
{i > 0 && <Divider key={`error-divider${i}`} />}
<ListItem>
<ListItemText
primary={error.message}
secondary={onlyPodWithErrors.pod.metadata?.name}
/>
<Grid container>
<Grid item xs={9}>
<ListItemText
primary={error.message}
secondary={onlyPodWithErrors.pod.metadata?.name}
/>
</Grid>
<Grid item xs={3}>
<FixDialog
pod={onlyPodWithErrors.pod}
error={error}
clusterName={onlyPodWithErrors.clusterName}
/>
</Grid>
</Grid>
</ListItem>
</React.Fragment>
);
@@ -0,0 +1,96 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { EventsContent } from './Events';
import { render } from '@testing-library/react';
import { Event } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
describe('EventsContent', () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
it('should show info events', () => {
const { getByText } = render(
<EventsContent
events={[
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
} as Event,
]}
/>,
);
expect(getByText('First event 1 hour ago (count: 52)')).toBeInTheDocument();
expect(getByText('something happened: hello there')).toBeInTheDocument();
});
it('should show warning events', () => {
const { getByText } = render(
<EventsContent
events={[
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
} as Event,
]}
/>,
);
expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument();
expect(getByText('something happened: uh oh')).toBeInTheDocument();
});
it('should only show warning events when warningEventsOnly set', () => {
const { getByText, queryByText } = render(
<EventsContent
warningEventsOnly
events={
[
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
},
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
] as Event[]
}
/>,
);
expect(queryByText('First event 1 hour ago (count: 52)')).toBeNull();
expect(queryByText('something happened: hello there')).toBeNull();
expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument();
expect(getByText('something happened: uh oh')).toBeInTheDocument();
});
});
+154
View File
@@ -0,0 +1,154 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Avatar,
Container,
Grid,
List,
ListItem,
ListItemAvatar,
ListItemText,
Tooltip,
Typography,
} from '@material-ui/core';
import InfoIcon from '@material-ui/icons/Info';
import WarningIcon from '@material-ui/icons/Warning';
import { DateTime } from 'luxon';
import { useEvents } from './useEvents';
import { Skeleton } from '@material-ui/lab';
import { DismissableBanner } from '@backstage/core-components';
import { Event } from 'kubernetes-models/v1';
/**
* Props for Events
*
* @public
*/
export interface EventsContentProps {
warningEventsOnly?: boolean;
events: Event[];
}
const getAvatarByType = (type?: string) => {
return (
<ListItemAvatar>
<Avatar>{type === 'Warning' ? <WarningIcon /> : <InfoIcon />}</Avatar>
</ListItemAvatar>
);
};
/**
* Shows given Kubernetes events
*
* @public
*/
export const EventsContent = ({
events,
warningEventsOnly,
}: EventsContentProps) => {
if (events.length === 0) {
return <Typography>No events found</Typography>;
}
return (
<Container>
<Grid>
<List>
{events
.filter(event => {
if (warningEventsOnly) {
return event.type === 'Warning';
}
return true;
})
.map(event => {
const timeAgo = event.metadata.creationTimestamp
? DateTime.fromISO(event.metadata.creationTimestamp).toRelative(
{
locale: 'en',
},
)
: 'unknown';
return (
<ListItem key={event.metadata.name}>
<Tooltip title={`${event.type ?? ''} event`}>
{getAvatarByType(event.type)}
</Tooltip>
<ListItemText
primary={`First event ${timeAgo} (count: ${event.count})`}
secondary={`${event.reason}: ${event.message}`}
/>
</ListItem>
);
})}
</List>
</Grid>
</Container>
);
};
/**
* Props for Events
*
* @public
*/
export interface EventsProps {
involvedObjectName: string;
namespace: string;
clusterName: string;
warningEventsOnly?: boolean;
}
/**
* Retrieves and shows Kubernetes events for the given object
*
* @public
*/
export const Events = ({
involvedObjectName,
namespace,
clusterName,
warningEventsOnly,
}: EventsProps) => {
const { value, error, loading } = useEvents({
involvedObjectName,
namespace,
clusterName,
});
return (
<>
{error && (
<DismissableBanner
{...{
message: error.message,
variant: 'error',
fixed: false,
}}
id="events"
/>
)}
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!loading && value !== undefined && (
<EventsContent warningEventsOnly={warningEventsOnly} events={value} />
)}
</>
);
};
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Events';
export * from './useEvents';
@@ -0,0 +1,68 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import { renderHook } from '@testing-library/react-hooks';
import { useEvents } from './useEvents';
import { DateTime } from 'luxon';
jest.mock('@backstage/core-plugin-api');
jest.mock('@backstage/plugin-kubernetes', () => ({
kubernetesProxyApiRef: () => jest.fn(),
}));
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
const response = [
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
] as any;
describe('Events', () => {
const mockGetEventsByInvolvedObjectName = jest.fn();
afterEach(() => {
jest.resetAllMocks();
});
it('should fetch and show events', async () => {
(useApi as any).mockReturnValue({
getEventsByInvolvedObjectName:
mockGetEventsByInvolvedObjectName.mockResolvedValue(response),
});
const { result, waitForNextUpdate } = renderHook(() =>
useEvents({
involvedObjectName: 'some-objecgt',
namespace: 'some-namespace',
clusterName: 'some-cluster',
}),
);
expect(result.current.loading).toEqual(true);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.value).toStrictEqual(response);
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { kubernetesProxyApiRef } from '../../../api';
/**
* Arguments for useEvents
*
* @public
*/
export interface EventsOptions {
involvedObjectName: string;
namespace: string;
clusterName: string;
}
/**
* Retrieves the events for the given object
*
* @public
*/
export const useEvents = ({
involvedObjectName,
namespace,
clusterName,
}: EventsOptions) => {
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
return useAsync(async () => {
return await kubernetesProxyApi.getEventsByInvolvedObjectName({
involvedObjectName,
namespace,
clusterName,
});
}, [involvedObjectName, namespace, clusterName]);
};
@@ -0,0 +1,159 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { FixDialog } from './FixDialog';
import { Pod } from 'kubernetes-models/v1/Pod';
jest.mock('../Events', () => ({
Events: () => {
return <React.Fragment data-testid="events" />;
},
}));
jest.mock('../PodLogs', () => ({
PodLogs: () => {
return <React.Fragment data-testid="logs" />;
},
}));
describe('FixDialog', () => {
it('docs link should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'docs',
docsLink: 'http://google.com',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Open docs')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
it('events button should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'events',
podName: 'some-pod',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Events:')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
it('Logs button should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occuranceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Crash logs:')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
});
@@ -0,0 +1,187 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { Button, Grid } from '@material-ui/core';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import CloseIcon from '@material-ui/icons/Close';
import HelpIcon from '@material-ui/icons/Help';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Pod } from 'kubernetes-models/v1/Pod';
import { DetectedError } from '../../../error-detection';
import { PodLogs } from '../PodLogs';
import { Events } from '../Events';
import { LinkButton } from '@backstage/core-components';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Props for FixDialog
*
* @public
*/
export interface FixDialogProps {
open?: boolean;
clusterName: string;
pod: Pod;
error: DetectedError;
}
/**
* A dialog for fixing detected Kubernetes errors
*
* @public
*/
export const FixDialog: React.FC<FixDialogProps> = ({
open,
pod,
error,
clusterName,
}: FixDialogProps) => {
const [isOpen, setOpen] = useState(!!open);
const classes = useStyles();
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
const pf = error.proposedFix;
const dialogContent = () => {
return (
<Grid container>
<Grid item xs={12}>
<Typography variant="h6">Detected error:</Typography>
<Typography>{error.message}</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="h6">Cause explanation:</Typography>
<Typography>
{error.proposedFix?.rootCauseExplanation ?? 'unknown'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="h6">Fix:</Typography>
<Typography>
<ul>
{(error.proposedFix?.actions ?? []).map((fix, i) => {
return (
<li key={`${pod.metadata?.name ?? 'unknown'}-pf-${i}`}>
{fix}
</li>
);
})}
</ul>
</Typography>
</Grid>
{pf && pf.type === 'logs' && (
<>
<Grid item xs={12}>
<Typography variant="h6">Crash logs:</Typography>
</Grid>
<Grid item xs={9}>
<PodLogs
previous
containerScope={{
podName: pod.metadata?.name ?? 'unknown',
podNamespace: pod.metadata?.namespace ?? 'unknown',
clusterName: clusterName,
containerName: pf.container,
}}
/>
</Grid>
</>
)}
{pf && pf.type === 'events' && (
<>
<Grid item xs={12}>
<Typography variant="h6">Events:</Typography>
</Grid>
<Grid item xs={9}>
<Events
warningEventsOnly
involvedObjectName={pod.metadata?.name ?? ''}
namespace={pod.metadata?.namespace ?? ''}
clusterName={clusterName}
/>
</Grid>
</>
)}
</Grid>
);
};
return (
<>
<Button
variant="outlined"
aria-label="fix issue"
component="label"
onClick={openDialog}
startIcon={<HelpIcon />}
>
Help
</Button>
<Dialog maxWidth="xl" fullWidth open={isOpen} onClose={closeDialog}>
<DialogTitle id="dialog-title">
{pod.metadata?.name} - {error.type}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>{dialogContent()}</DialogContent>
<DialogActions>
{pf && pf.type === 'docs' && (
<LinkButton
to={pf.docsLink}
variant="outlined"
startIcon={<OpenInNewIcon />}
target="_blank"
rel="noopener"
>
Open docs
</LinkButton>
)}
</DialogActions>
</Dialog>
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './FixDialog';
@@ -83,12 +83,22 @@ const ContainerDatetime = ({ prefix, dateTime }: ContainerDatetimeProps) => {
);
};
/**
* Props for ContainerCard
*
* @public
*/
export interface ContainerCardProps {
podScope: PodScope;
containerSpec?: IContainer;
containerStatus: IContainerStatus;
}
/**
* Shows details about a container within a pod
*
* @public
*/
export const ContainerCard: React.FC<ContainerCardProps> = ({
podScope,
containerSpec,
@@ -162,7 +172,7 @@ export const ContainerCard: React.FC<ContainerCardProps> = ({
</CardContent>
<CardActions disableSpacing>
<PodLogsDialog
podScope={{
containerScope={{
containerName: containerStatus.name,
...podScope,
}}
@@ -27,7 +27,7 @@ interface PodConditionProps {
condition: IPodCondition;
}
export const PodCondition = ({ condition }: PodConditionProps) => {
const PodCondition = ({ condition }: PodConditionProps) => {
return (
<>
{condition.status === 'False' && (
@@ -64,10 +64,20 @@ export const PodCondition = ({ condition }: PodConditionProps) => {
);
};
interface PendingPodContentProps {
/**
* Props for PendingPodContent
*
* @public
*/
export interface PendingPodContentProps {
pod: Pod;
}
/**
* Shows details about pod's conditions as it starts
*
* @public
*/
export const PendingPodContent = ({ pod }: PendingPodContentProps) => {
// TODO add PodHasNetwork when it's out of alpha
// https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions
@@ -69,7 +69,7 @@ describe('PodDrawer', () => {
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
actions: ['fix1', 'fix2'],
},
],
},
@@ -58,11 +58,22 @@ const useDrawerContentStyles = makeStyles((_theme: Theme) =>
function getContainerSpecByName(pod: Pod, containerName: string) {
return pod.spec?.containers.find(c => c.name === containerName);
}
/**
* Props for PodDrawer
*
* @public
*/
interface PodDrawerProps {
open?: boolean;
podAndErrors: PodAndErrors;
}
/**
* A Drawer for Kubernetes Pods
*
* @public
*/
export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
const classes = useDrawerContentStyles();
+3 -1
View File
@@ -13,4 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { PodDrawer } from './PodDrawer';
export * from './PodDrawer';
export * from './ContainerCard';
export * from './PendingPodContent';
+36 -7
View File
@@ -15,20 +15,39 @@
*/
import React from 'react';
import { DismissableBanner, LogViewer } from '@backstage/core-components';
import {
DismissableBanner,
EmptyState,
LogViewer,
} from '@backstage/core-components';
import { Paper } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { ContainerScope } from './types';
import { usePodLogs } from './usePodLogs';
interface PodLogsProps {
podScope: ContainerScope;
/**
* Props for PodLogs
*
* @public
*/
export interface PodLogsProps {
containerScope: ContainerScope;
previous?: boolean;
}
export const PodLogs: React.FC<PodLogsProps> = ({ podScope }: PodLogsProps) => {
/**
* Shows the logs for the given pod
*
* @public
*/
export const PodLogs: React.FC<PodLogsProps> = ({
containerScope,
previous,
}: PodLogsProps) => {
const { value, error, loading } = usePodLogs({
podScope: podScope,
containerScope,
previous,
});
return (
@@ -45,10 +64,20 @@ export const PodLogs: React.FC<PodLogsProps> = ({ podScope }: PodLogsProps) => {
)}
<Paper
elevation={1}
style={{ height: '100%', width: '100%', minHeight: '30rem' }}
style={{ height: '100%', width: '100%', minHeight: '15rem' }}
>
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!loading && value !== undefined && <LogViewer text={value.text} />}
{!loading &&
value !== undefined &&
(value.text === '' ? (
<EmptyState
missing="data"
title="No logs emitted"
description="No logs were emitted by the container"
/>
) : (
<LogViewer text={value.text} />
))}
</Paper>
</>
);
@@ -43,11 +43,21 @@ const useStyles = makeStyles((theme: Theme) =>
}),
);
interface PodLogsDialogProps {
podScope: ContainerScope;
/**
* Props for PodLogsDialog
*
* @public
*/
export interface PodLogsDialogProps {
containerScope: ContainerScope;
}
export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => {
/**
* Shows the logs for the given pod in a Dialog
*
* @public
*/
export const PodLogsDialog = ({ containerScope }: PodLogsDialogProps) => {
const classes = useStyles();
const [open, setOpen] = useState(false);
@@ -62,8 +72,8 @@ export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => {
<>
<Dialog maxWidth="xl" fullWidth open={open} onClose={closeDialog}>
<DialogTitle id="dialog-title">
{podScope.podName} - {podScope.containerName} logs on cluster{' '}
{podScope.clusterName}
{containerScope.podName} - {containerScope.containerName} logs on
cluster {containerScope.clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
@@ -73,7 +83,7 @@ export const PodLogsDialog = ({ podScope }: PodLogsDialogProps) => {
</IconButton>
</DialogTitle>
<DialogContent>
<PodLogs podScope={podScope} />
<PodLogs containerScope={containerScope} />
</DialogContent>
</Dialog>
<Button
+10
View File
@@ -14,12 +14,22 @@
* limitations under the License.
*/
/**
* Contains the details needed to make a log request to Kubernetes, except the container name
*
* @public
*/
export interface PodScope {
podName: string;
podNamespace: string;
clusterName: string;
}
/**
* Contains the details needed to make a log request to Kubernetes
*
* @public
*/
export interface ContainerScope extends PodScope {
containerName: string;
}
+20 -8
View File
@@ -19,18 +19,30 @@ import { ContainerScope } from './types';
import { useApi } from '@backstage/core-plugin-api';
import { kubernetesProxyApiRef } from '../../../api';
interface PodLogsOptions {
podScope: ContainerScope;
/**
* Arguments for usePodLogs
*
* @public
*/
export interface PodLogsOptions {
containerScope: ContainerScope;
previous?: boolean;
}
export const usePodLogs = ({ podScope }: PodLogsOptions) => {
/**
* Retrieves the logs for the given pod
*
* @public
*/
export const usePodLogs = ({ containerScope, previous }: PodLogsOptions) => {
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
return useAsync(async () => {
return await kubernetesProxyApi.getPodLogs({
podName: podScope.podName,
namespace: podScope.podNamespace,
containerName: podScope.containerName,
clusterName: podScope.clusterName,
podName: containerScope.podName,
namespace: containerScope.podNamespace,
containerName: containerScope.containerName,
clusterName: containerScope.clusterName,
previous,
});
}, [JSON.stringify(podScope)]);
}, [JSON.stringify(containerScope)]);
};
+6 -1
View File
@@ -13,5 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { PodDrawer } from './PodDrawer';
export * from './PodDrawer';
export * from './PodLogs';
export * from './FixDialog';
export * from './Events';
export * from './ErrorList';
export { PodsTable } from './PodsTable';
export * from './types';
+5
View File
@@ -16,6 +16,11 @@
import { Pod } from 'kubernetes-models/v1';
import { DetectedError } from '../../error-detection';
/**
* Wraps a pod with the associated detected errors and cluster name
*
* @public
*/
export interface PodAndErrors {
clusterName: string;
pod: Pod;
@@ -28,7 +28,6 @@ const deploymentErrorMappers: ErrorMapper<Deployment>[] = [
type: 'condition-message-present',
message: c.message ?? '',
severity: 6,
proposedFix: [], // TODO next PR
sourceRef: {
name: deployment.metadata?.name ?? 'unknown hpa',
namespace: deployment.metadata?.namespace ?? 'unknown namespace',
@@ -156,8 +156,15 @@ describe('detectErrors', () => {
message:
'back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
severity: 4,
proposedFix: {
container: 'other-side-car',
errorType: 'CrashLoopBackOff',
actions: ['Check the crash logs for stacktraces'],
rootCauseExplanation:
'The container other-side-car has crashed many times, it will be exponentially restarted until it stops crashing',
type: 'logs',
},
occuranceCount: 1,
proposedFix: [],
type: 'container-waiting',
});
@@ -168,11 +175,18 @@ describe('detectErrors', () => {
name: 'dice-roller-canary-7d64cd756c-55rfq',
namespace: 'default',
},
proposedFix: {
container: 'other-side-car',
errorType: 'CrashLoopBackOff',
actions: ['Check the crash logs for stacktraces'],
rootCauseExplanation:
'The container other-side-car has crashed many times, it will be exponentially restarted until it stops crashing',
type: 'logs',
},
message:
'back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
severity: 4,
occuranceCount: 1,
proposedFix: [],
type: 'container-waiting',
});
@@ -186,7 +200,14 @@ describe('detectErrors', () => {
message: 'container=other-side-car restarted 123 times',
severity: 4,
occuranceCount: 123,
proposedFix: [],
proposedFix: {
container: 'other-side-car',
errorType: 'Error',
actions: ['Check the crash logs for stacktraces'],
rootCauseExplanation:
'This container has exited with a non-zero exit code (1)',
type: 'logs',
},
type: 'containers-restarting',
});
@@ -197,10 +218,17 @@ describe('detectErrors', () => {
name: 'dice-roller-canary-7d64cd756c-55rfq',
namespace: 'default',
},
proposedFix: {
container: 'other-side-car',
errorType: 'Error',
actions: ['Check the crash logs for stacktraces'],
rootCauseExplanation:
'This container has exited with a non-zero exit code (1)',
type: 'logs',
},
message: 'container=side-car restarted 38 times',
severity: 4,
occuranceCount: 38,
proposedFix: [],
type: 'containers-restarting',
});
});
@@ -219,7 +247,18 @@ describe('detectErrors', () => {
expect(err1).toStrictEqual({
message: 'configmap "some-cm" not found',
occuranceCount: 1,
proposedFix: [],
proposedFix: {
docsLink:
'https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/',
errorType: 'CreateContainerConfigError',
actions: [
'Ensure ConfigMaps references in the Deployment manifest are correct and the keys exist',
'Ensure Secrets references in the Deployment manifest are correct and the keys exist',
],
rootCauseExplanation:
'There is missing or mismatching configuration required to start the container',
type: 'docs',
},
severity: 4,
sourceRef: {
apiGroup: 'v1',
@@ -262,7 +301,6 @@ describe('detectErrors', () => {
message: 'Deployment does not have minimum availability.',
severity: 6,
occuranceCount: 1,
proposedFix: [],
type: 'condition-message-present',
});
});
@@ -299,7 +337,6 @@ describe('detectErrors', () => {
'Current number of replicas (10) is equal to the configured max number of replicas (10)',
severity: 8,
occuranceCount: 1,
proposedFix: [],
type: 'hpa-max-current-replicas',
});
});
@@ -433,7 +470,17 @@ describe('detectErrors', () => {
message:
'The container some-container failed to start properly, but is not crashing',
occuranceCount: 1,
proposedFix: [],
proposedFix: {
errorType: 'ReadinessProbeFailed',
podName: '',
actions: [
'Ensure that the container starts correctly locally',
"Check the container's logs looking for error during startup",
],
rootCauseExplanation:
'The container some-container failed to start properly, but is not crashing',
type: 'events',
},
severity: 4,
sourceRef: {
apiGroup: 'v1',
@@ -31,7 +31,6 @@ const hpaErrorMappers: ErrorMapper<HorizontalPodAutoscaler>[] = [
hpa.spec?.maxReplicas ?? -1
})`,
severity: 8,
proposedFix: [], // TODO next PR
sourceRef: {
name: hpa.metadata?.name ?? 'unknown hpa',
namespace: hpa.metadata?.namespace ?? 'unknown namespace',
+127 -4
View File
@@ -15,7 +15,7 @@
*/
import { Pod, IContainerStatus, IContainer } from 'kubernetes-models/v1';
import { DetectedError, ErrorMapper } from './types';
import { DetectedError, ErrorMapper, ProposedFix } from './types';
import { detectErrorsInObjects } from './common';
import lodash from 'lodash';
import { DateTime } from 'luxon';
@@ -70,6 +70,129 @@ const podToContainerSpecsAndStatuses = (pod: Pod): ContainerSpecAndStatus[] => {
return result;
};
const readinessProbeProposedFixes = (pod: Pod): ProposedFix | undefined => {
const firstUnreadyContainerStatus = pod.status?.containerStatuses?.find(
cs => {
return cs.ready === false;
},
);
return {
errorType: 'ReadinessProbeFailed',
rootCauseExplanation: `The container ${firstUnreadyContainerStatus?.name} failed to start properly, but is not crashing`,
actions: [
'Ensure that the container starts correctly locally',
"Check the container's logs looking for error during startup",
],
type: 'events',
podName: pod.metadata?.name ?? '',
};
};
const restartingPodProposedFixes = (pod: Pod): ProposedFix | undefined => {
const lastTerminatedCs = (pod.status?.containerStatuses ?? []).find(
cs => cs.lastState?.terminated !== undefined,
);
const lastTerminated = lastTerminatedCs?.lastState?.terminated;
if (!lastTerminated) {
return undefined;
}
switch (lastTerminated?.reason) {
case 'Unknown':
return {
// TODO check this one, it's more likely a cluster issue
errorType: 'Unknown',
rootCauseExplanation: `This container has exited with a non-zero exit code (${lastTerminated.exitCode})`,
actions: ['Check the crash logs for stacktraces'],
container: lastTerminatedCs.name,
type: 'logs',
};
case 'Error':
return {
errorType: 'Error',
rootCauseExplanation: `This container has exited with a non-zero exit code (${lastTerminated.exitCode})`,
actions: ['Check the crash logs for stacktraces'],
container: lastTerminatedCs.name,
type: 'logs',
};
case 'OOMKilled':
return {
errorType: 'OOMKilled',
rootCauseExplanation: `The container "${lastTerminatedCs.name}" has crashed because it has tried to use more memory that it has been allocated`,
actions: [
`Increase the amount of memory assigned to the container`,
'Ensure the application is memory bounded and is not trying to consume too much memory',
],
docsLink:
'https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/#exceed-a-container-s-memory-limit',
type: 'docs',
};
default:
return undefined;
}
};
const waitingProposedFix = (pod: Pod): ProposedFix | undefined => {
const waitingCs = (pod.status?.containerStatuses ?? []).find(
cs => cs.state?.waiting !== undefined,
);
const waiting = (pod.status?.containerStatuses ?? [])
.map(cs => cs.state?.waiting)
.find(w => w?.reason !== undefined);
switch (waiting?.reason) {
case 'InvalidImageName':
return {
errorType: 'InvalidImageName',
rootCauseExplanation: 'The image in the pod is invalid',
actions: ['Ensure the image name is correct and valid image name'],
type: 'docs',
docsLink:
'https://docs.docker.com/engine/reference/commandline/tag/#extended-description',
};
case 'ImagePullBackOff':
return {
errorType: 'ImagePullBackOff',
rootCauseExplanation:
'The image either could not be found or Kubernetes does not have permission to pull it',
actions: [
'Ensure the image name is correct',
'Ensure Kubernetes has permission to pull this image',
],
type: 'docs',
docsLink:
'https://kubernetes.io/docs/concepts/containers/images/#imagepullbackoff',
};
case 'CrashLoopBackOff':
return {
errorType: 'CrashLoopBackOff',
rootCauseExplanation: `The container ${waitingCs?.name} has crashed many times, it will be exponentially restarted until it stops crashing`,
actions: ['Check the crash logs for stacktraces'],
type: 'logs',
container: waitingCs?.name ?? 'unknown',
};
case 'CreateContainerConfigError':
return {
errorType: 'CreateContainerConfigError',
rootCauseExplanation:
'There is missing or mismatching configuration required to start the container',
actions: [
'Ensure ConfigMaps references in the Deployment manifest are correct and the keys exist',
'Ensure Secrets references in the Deployment manifest are correct and the keys exist',
],
type: 'docs',
docsLink:
'https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/',
};
default:
return undefined;
}
};
const podErrorMappers: ErrorMapper<Pod>[] = [
{
detectErrors: pod => {
@@ -79,7 +202,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
type: 'readiness-probe-taking-too-long',
message: `The container ${cs.container.name} failed to start properly, but is not crashing`,
severity: 4,
proposedFix: [], // TODO next PR
proposedFix: readinessProbeProposedFixes(pod),
sourceRef: {
name: pod.metadata?.name ?? 'unknown pod',
namespace: pod.metadata?.namespace ?? 'unknown namespace',
@@ -98,7 +221,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
type: 'container-waiting',
message: cs.state?.waiting?.message ?? 'container waiting',
severity: 4,
proposedFix: [], // TODO next PR
proposedFix: waitingProposedFix(pod),
sourceRef: {
name: pod.metadata?.name ?? 'unknown pod',
namespace: pod.metadata?.namespace ?? 'unknown namespace',
@@ -117,7 +240,7 @@ const podErrorMappers: ErrorMapper<Pod>[] = [
type: 'containers-restarting',
message: `container=${cs.name} restarted ${cs.restartCount} times`,
severity: 4,
proposedFix: [], // TODO next PR
proposedFix: restartingPodProposedFixes(pod),
sourceRef: {
name: pod.metadata?.name ?? 'unknown pod',
namespace: pod.metadata?.namespace ?? 'unknown namespace',
@@ -44,17 +44,17 @@ export interface DetectedError {
type: string;
severity: ErrorSeverity;
message: string;
proposedFix: ProposedFix[];
proposedFix?: ProposedFix;
sourceRef: ResourceRef;
occuranceCount: number;
}
type ProposedFix = LogSolution | DocsSolution | EventsSolution;
export type ProposedFix = LogSolution | DocsSolution | EventsSolution;
interface ProposedFixBase {
errorType: string;
rootCauseExplanation: string;
possibleFixes: string[];
actions: string[];
}
export interface LogSolution extends ProposedFixBase {
@@ -69,7 +69,6 @@ export interface DocsSolution extends ProposedFixBase {
export interface EventsSolution extends ProposedFixBase {
type: 'events';
docsLink: string;
podName: string;
}
@@ -26,15 +26,13 @@ const genericErrorWithRef = (resourceRef: ResourceRef): DetectedError => {
message: 'some error message',
occuranceCount: 1,
sourceRef: resourceRef,
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
possibleFixes: ['fix1', 'fix2'],
},
],
proposedFix: {
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
};
};