From f5882921756a7aa6d5a74d8fdb6bfc52a7c0c209 Mon Sep 17 00:00:00 2001 From: Karthik Jeeyar Date: Thu, 14 Dec 2023 11:41:07 +0530 Subject: [PATCH] feat(tekton): add support for downloading task and pipelinerun logs (#1014) * feat(tekton): add support for downloading task and pipelinerun logs * add unit tests * refactor code and add more unit tests * add logs streaming in dev mode --- plugins/shared-react/package.json | 2 + .../utils/downloader/logs-downloader.test.ts} | 2 +- .../src/utils/downloader/logs-downloader.ts} | 0 plugins/shared-react/src/utils/index.ts | 1 + plugins/tekton/dev/index.tsx | 19 +++ .../src/__fixtures__/1-pipelinesData.ts | 39 ++++++ plugins/tekton/src/__fixtures__/pods-data.ts | 116 ++++++++++++++++ .../PipelineRunLogs/PipelineRunLogDialog.tsx | 21 ++- .../PipelineRunLogDownloader.tsx | 68 ++++++++++ .../PipelineRunLogs/PipelineRunLogs.tsx | 11 +- .../PipelineRunLogs/PodLogsDownloadLink.tsx | 85 ++++++++++++ .../__tests__/PipelineRunLogDialog.test.tsx | 106 +++++++++++++++ .../PipelineRunLogDownloader.test.tsx | 43 ++++++ .../__tests__/PodLogsDownloadLink.test.tsx | 124 ++++++++++++++++++ plugins/tekton/src/consts/tekton-const.ts | 1 + .../src/utils/log-downloader-utils.test.ts | 46 +++++++ .../tekton/src/utils/log-downloader-utils.ts | 45 +++++++ plugins/topology/package.json | 2 - .../PodLogs/PodLogsDownload.test.tsx | 5 +- .../PodLogs/PodLogsDownload.tsx | 2 +- 20 files changed, 723 insertions(+), 15 deletions(-) rename plugins/{topology/src/utils/pod-log-utils.test.ts => shared-react/src/utils/downloader/logs-downloader.test.ts} (90%) rename plugins/{topology/src/utils/pod-log-utils.ts => shared-react/src/utils/downloader/logs-downloader.ts} (100%) create mode 100644 plugins/tekton/src/__fixtures__/pods-data.ts create mode 100644 plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDownloader.tsx create mode 100644 plugins/tekton/src/components/PipelineRunLogs/PodLogsDownloadLink.tsx create mode 100644 plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDialog.test.tsx create mode 100644 plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDownloader.test.tsx create mode 100644 plugins/tekton/src/components/PipelineRunLogs/__tests__/PodLogsDownloadLink.test.tsx create mode 100644 plugins/tekton/src/utils/log-downloader-utils.test.ts create mode 100644 plugins/tekton/src/utils/log-downloader-utils.ts diff --git a/plugins/shared-react/package.json b/plugins/shared-react/package.json index f659883958..554cd008cd 100644 --- a/plugins/shared-react/package.json +++ b/plugins/shared-react/package.json @@ -26,6 +26,7 @@ "@kubernetes/client-node": "^0.19.0", "classnames": "^2.3.2", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "lodash": "^4.17.21", "mathjs": "^11.11.2" }, @@ -41,6 +42,7 @@ "@testing-library/react": "12.1.5", "@testing-library/user-event": "14.5.1", "@types/node": "18.18.5", + "@types/file-saver": "2.0.6", "cross-fetch": "4.0.0", "msw": "1.3.2" }, diff --git a/plugins/topology/src/utils/pod-log-utils.test.ts b/plugins/shared-react/src/utils/downloader/logs-downloader.test.ts similarity index 90% rename from plugins/topology/src/utils/pod-log-utils.test.ts rename to plugins/shared-react/src/utils/downloader/logs-downloader.test.ts index 3a057d61cb..66069bee75 100644 --- a/plugins/topology/src/utils/pod-log-utils.test.ts +++ b/plugins/shared-react/src/utils/downloader/logs-downloader.test.ts @@ -1,6 +1,6 @@ import { saveAs } from 'file-saver'; -import { downloadLogFile } from './pod-log-utils'; +import { downloadLogFile } from './logs-downloader'; jest.mock('file-saver', () => ({ saveAs: jest.fn(), diff --git a/plugins/topology/src/utils/pod-log-utils.ts b/plugins/shared-react/src/utils/downloader/logs-downloader.ts similarity index 100% rename from plugins/topology/src/utils/pod-log-utils.ts rename to plugins/shared-react/src/utils/downloader/logs-downloader.ts diff --git a/plugins/shared-react/src/utils/index.ts b/plugins/shared-react/src/utils/index.ts index 34dc722eb6..a5e2df912d 100644 --- a/plugins/shared-react/src/utils/index.ts +++ b/plugins/shared-react/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './date'; export * from './pipeline'; export * from './unit-conversion'; +export * from './downloader/logs-downloader'; diff --git a/plugins/tekton/dev/index.tsx b/plugins/tekton/dev/index.tsx index 9199d1ddd1..56b5e0f737 100644 --- a/plugins/tekton/dev/index.tsx +++ b/plugins/tekton/dev/index.tsx @@ -7,6 +7,8 @@ import { EntityKubernetesContent, KubernetesApi, kubernetesApiRef, + KubernetesProxyApi, + kubernetesProxyApiRef, } from '@backstage/plugin-kubernetes'; import { TestApiProvider } from '@backstage/test-utils'; @@ -31,6 +33,21 @@ const mockEntity: Entity = { }, }; +class MockKubernetesProxyApi implements KubernetesProxyApi { + async getPodLogs(_request: any): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve({ + text: `\nstreaming logs from container: ${_request.containerName} \n...`, + }); + }, 500); + }); + } + + async getEventsByInvolvedObjectName(): Promise { + return {}; + } +} class MockKubernetesClient implements KubernetesApi { readonly resources; @@ -55,6 +72,7 @@ class MockKubernetesClient implements KubernetesApi { }, ); } + async getWorkloadsByEntity(_request: any): Promise { return { items: [ @@ -129,6 +147,7 @@ createDevApp() kubernetesApiRef, new MockKubernetesClient(mockKubernetesPlrResponse), ], + [kubernetesProxyApiRef, new MockKubernetesProxyApi()], ]} > diff --git a/plugins/tekton/src/__fixtures__/1-pipelinesData.ts b/plugins/tekton/src/__fixtures__/1-pipelinesData.ts index 9934a721c7..f57ad533d0 100644 --- a/plugins/tekton/src/__fixtures__/1-pipelinesData.ts +++ b/plugins/tekton/src/__fixtures__/1-pipelinesData.ts @@ -1,5 +1,42 @@ export const mockKubernetesPlrResponse = { pods: [ + { + metadata: { + name: 'pipeline-test-wbvtlk-tkn-pod', + namespace: 'karthik', + uid: 'bd868fde-1b37-4168-a780-f1772c5924e3', + resourceVersion: '379524', + labels: { + 'app.kubernetes.io/managed-by': 'tekton-pipelines', + 'backstage.io/kubernetes-id': 'developer-portal', + 'janus-idp.io/tekton': 'developer-portal', + 'tekton.dev/clusterTask': 'tkn', + 'tekton.dev/memberOf': 'tasks', + 'tekton.dev/pipeline': 'test-pipeline', + 'tekton.dev/pipelineRun': 'pipeline-test-wbvtlk', + 'tekton.dev/pipelineTask': 'tkn', + 'tekton.dev/taskRun': 'test-pipeline-8e09zm-task1', + }, + }, + spec: { + volumes: [ + { + name: 'tekton-internal-workspace', + emptyDir: {}, + }, + ], + containers: [ + { + name: 'step-tkn', + }, + ], + }, + status: { + phase: 'Succeeded', + conditions: [], + startTime: new Date('2023-12-08T12:19:29Z'), + }, + }, { metadata: { name: 'ruby-ex-git-xf45fo-build-pod', @@ -13,6 +50,8 @@ export const mockKubernetesPlrResponse = { 'backstage.io/kubernetes-id': 'backstage', deployment: 'ruby-ex-git', 'pod-template-hash': '66d547b559', + 'tekton.dev/pipelineRun': 'ruby-ex-git-xf45fo', + 'tekton.dev/pipelineTask': 'build', }, ownerReferences: [ { diff --git a/plugins/tekton/src/__fixtures__/pods-data.ts b/plugins/tekton/src/__fixtures__/pods-data.ts new file mode 100644 index 0000000000..212bf46dbc --- /dev/null +++ b/plugins/tekton/src/__fixtures__/pods-data.ts @@ -0,0 +1,116 @@ +import { V1Pod } from '@kubernetes/client-node'; + +import { PipelineRunKind } from '@janus-idp/shared-react'; + +export const testPipelineRun: PipelineRunKind = { + apiVersion: 'tekton.dev/v1', + kind: 'PipelineRun', + metadata: { + name: 'test-pipeline-8e09zm', + uid: '17080e46-1ff6-4f15-99e9-e32f603d7cc8', + creationTimestamp: new Date('2023-12-12T06:38:29Z'), + labels: { + 'backstage.io/kubernetes-id': 'developer-portal', + 'janus-idp.io/tekton': 'developer-portal', + 'tekton.dev/build-namespace': 'karthik', + 'tekton.dev/pipeline': 'new-pipeline', + }, + }, + spec: { + pipelineRef: { + name: 'new-pipeline', + }, + }, + status: { + completionTime: '2023-12-12T06:39:12Z', + pipelineSpec: { tasks: [] }, + conditions: [ + { + lastTransitionTime: '2023-12-12T06:39:12Z', + message: 'Tasks Completed: 3 (Failed: 0, Cancelled 0), Skipped: 0', + reason: 'Succeeded', + status: 'True', + type: 'Succeeded', + }, + ], + startTime: '2023-12-12T06:38:29Z', + }, +}; + +export const testPods: V1Pod[] = [ + { + metadata: { + name: 'test-pipeline-8e09zm-task1-pod', + namespace: 'karthik', + uid: 'bd868fde-1b37-4168-a780-f1772c5924e3', + resourceVersion: '379524', + labels: { + 'app.kubernetes.io/managed-by': 'tekton-pipelines', + 'backstage.io/kubernetes-id': 'developer-portal', + 'janus-idp.io/tekton': 'developer-portal', + 'tekton.dev/clusterTask': 'tkn', + 'tekton.dev/memberOf': 'tasks', + 'tekton.dev/pipeline': 'test-pipeline', + 'tekton.dev/pipelineRun': 'test-pipeline-8e09zm', + 'tekton.dev/pipelineTask': 'task1', + 'tekton.dev/taskRun': 'test-pipeline-8e09zm-task1', + }, + }, + spec: { + volumes: [ + { + name: 'tekton-internal-workspace', + emptyDir: {}, + }, + ], + containers: [ + { + name: 'step-tkn', + }, + ], + }, + status: { + phase: 'Succeeded', + conditions: [], + startTime: new Date('2023-12-08T12:19:29Z'), + }, + }, + { + metadata: { + name: 'test-pipeline-8e09zm-sbom-task-pod', + namespace: 'karthik', + uid: '055cc13a-bd3e-414e-9eb6-e6cb72870578', + resourceVersion: '379623', + labels: { + 'backstage.io/kubernetes-id': 'developer-portal', + 'janus-idp.io/tekton': 'developer-portal', + 'tekton.dev/pipeline': 'test-pipeline', + 'tekton.dev/pipelineRun': 'test-pipeline-8e09zm', + 'tekton.dev/pipelineTask': 'sbom-task', + 'tekton.dev/task': 'sbom-task', + 'tekton.dev/taskRun': 'test-pipeline-8e09zm-sbom-task', + }, + }, + spec: { + containers: [ + { + name: 'step-print-sbom-results', + }, + ], + }, + status: { + phase: 'Succeeded', + conditions: [], + + startTime: new Date('2023-12-08T12:19:38Z'), + }, + }, +]; + +export const testPipelineRunPods: { + pipelineRun: PipelineRunKind; + pods: V1Pod[]; +} = { + pipelineRun: testPipelineRun, + pods: testPods, +}; diff --git a/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDialog.tsx b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDialog.tsx index 19b30f3f1d..05b93f3570 100644 --- a/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDialog.tsx +++ b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDialog.tsx @@ -19,6 +19,7 @@ import { PipelineRunKind, TaskRunKind } from '@janus-idp/shared-react'; import { tektonGroupColor } from '../../types/types'; import ResourceBadge from '../PipelineRunList/ResourceBadge'; +import PipelineRunLogDownloader from './PipelineRunLogDownloader'; import PipelineRunLogs from './PipelineRunLogs'; const useStyles = makeStyles((theme: Theme) => @@ -55,14 +56,22 @@ const PipelineRunLogDialog = ({ }: PipelineRunLogDialogProps) => { const classes = useStyles(); + const [task, setTask] = React.useState(activeTask); + return ( - + + diff --git a/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDownloader.tsx b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDownloader.tsx new file mode 100644 index 0000000000..5a726d877c --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogDownloader.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { V1Pod } from '@kubernetes/client-node'; +import { Flex, FlexItem } from '@patternfly/react-core'; + +import { PipelineRunKind } from '@janus-idp/shared-react'; + +import { + TEKTON_PIPELINE_RUN, + TEKTON_PIPELINE_TASK, + TEKTON_PIPELINE_TASKRUN, +} from '../../consts/tekton-const'; +import PodLogsDownloadLink from './PodLogsDownloadLink'; + +const PipelineRunLogDownloader: React.FC<{ + pods: V1Pod[]; + pipelineRun: PipelineRunKind; + activeTask: string | undefined; +}> = ({ pods, pipelineRun, activeTask }) => { + const filteredPods: V1Pod[] = pods?.filter( + (p: V1Pod) => + p?.metadata?.labels?.[TEKTON_PIPELINE_RUN] === + pipelineRun?.metadata?.name, + ); + + const sortedPods: V1Pod[] = React.useMemo( + () => + Array.from(filteredPods)?.sort( + (a: V1Pod, b: V1Pod) => + new Date(a?.status?.startTime as Date).getTime() - + new Date(b?.status?.startTime as Date).getTime(), + ), + [filteredPods], + ); + + const activeTaskPod: V1Pod = + sortedPods.find( + (sp: V1Pod) => + sp.metadata?.labels?.[TEKTON_PIPELINE_TASKRUN] === activeTask, + ) ?? sortedPods[sortedPods.length - 1]; + + return sortedPods.length > 0 ? ( + + + + + + + + + ) : null; +}; +export default PipelineRunLogDownloader; diff --git a/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogs.tsx b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogs.tsx index fc7f50294a..f4096fef50 100644 --- a/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogs.tsx +++ b/plugins/tekton/src/components/PipelineRunLogs/PipelineRunLogs.tsx @@ -22,12 +22,14 @@ type PipelineRunLogsProps = { taskRuns: TaskRunKind[]; pods: V1Pod[]; activeTask?: string; + setActiveTask: (t: string) => void; }; export const PipelineRunLogs = ({ pipelineRun, taskRuns, pods, activeTask, + setActiveTask, }: PipelineRunLogsProps) => { const PLRTaskRuns = getTaskRunsForPipelineRun(pipelineRun, taskRuns); const sortedTaskRuns = getSortedTaskRuns(PLRTaskRuns); @@ -42,9 +44,6 @@ export const PipelineRunLogs = ({ ); const completed = pipelineRunFilterReducer(pipelineRun); - const [userSelectedStepId, setUserSelectedStepId] = React.useState( - activeTask ?? '', - ); const [lastActiveStepId, setLastActiveStepId] = React.useState(''); React.useEffect(() => { @@ -62,7 +61,7 @@ export const PipelineRunLogs = ({ ); }, [sortedTaskRuns, completed, activeTask]); - const currentStepId = userSelectedStepId || lastActiveStepId; + const currentStepId = activeTask || lastActiveStepId; const activeItem = getActiveTaskRun(sortedTaskRuns, currentStepId); const podName = activeItem && taskRunFromYaml?.[currentStepId]?.status?.podName; @@ -81,7 +80,7 @@ export const PipelineRunLogs = ({ @@ -96,7 +95,7 @@ export const PipelineRunLogs = ({ ) : ( - + )} diff --git a/plugins/tekton/src/components/PipelineRunLogs/PodLogsDownloadLink.tsx b/plugins/tekton/src/components/PipelineRunLogs/PodLogsDownloadLink.tsx new file mode 100644 index 0000000000..c99444a33d --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunLogs/PodLogsDownloadLink.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesProxyApiRef } from '@backstage/plugin-kubernetes'; + +import { V1Pod } from '@kubernetes/client-node'; +import { createStyles, Link, makeStyles, Theme } from '@material-ui/core'; +import { DownloadIcon } from '@patternfly/react-icons'; +import classNames from 'classnames'; + +import { downloadLogFile } from '@janus-idp/shared-react'; + +import { TektonResourcesContext } from '../../hooks/TektonResourcesContext'; +import { ContainerScope } from '../../hooks/usePodLogsOfPipelineRun'; +import { TektonResourcesContextData } from '../../types/types'; +import { getPodLogs } from '../../utils/log-downloader-utils'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + downloadAction: { + position: 'relative', + marginBottom: 'var(--pf-v5-global--spacer--sm)', + color: 'var(--pf-v5-global--color--100)', + cursor: 'pointer', + }, + buttonDisabled: { + color: theme.palette.grey[400], + cursor: 'not-allowed', + }, + }), +); + +const PodLogsDownloadLink: React.FC<{ + pods: V1Pod[]; + fileName: string; + downloadTitle: string; +}> = ({ pods, fileName, downloadTitle, ...props }): React.ReactElement => { + const classes = useStyles(); + const [downloading, setDownloading] = React.useState(false); + const kubernetesProxyApi = useApi(kubernetesProxyApiRef); + + const { clusters, selectedCluster = 0 } = + React.useContext(TektonResourcesContext); + const currCluster = clusters.length > 0 ? clusters[selectedCluster] : ''; + + const getLogs = (podScope: ContainerScope): Promise<{ text: string }> => { + const { podName, podNamespace, containerName, clusterName } = podScope; + return kubernetesProxyApi.getPodLogs({ + podName: podName, + namespace: podNamespace, + containerName: containerName, + clusterName: clusterName, + }); + }; + + return ( + { + setDownloading(true); + getPodLogs(pods, getLogs, currCluster) + .then((logs: string) => { + setDownloading(false); + downloadLogFile(logs || '', fileName); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.warn('Download failed', err); + setDownloading(false); + }); + }} + className={classNames(classes.downloadAction, { + [classes.buttonDisabled]: downloading, + })} + {...props} + > + {downloadTitle || 'Download '} + + ); +}; +export default PodLogsDownloadLink; diff --git a/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDialog.test.tsx b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDialog.test.tsx new file mode 100644 index 0000000000..02af8379cb --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDialog.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { useApi } from '@backstage/core-plugin-api'; + +import { Theme } from '@material-ui/core'; +import { render, screen } from '@testing-library/react'; + +import { testPipelineRunPods } from '../../../__fixtures__/pods-data'; +import PipelineRunLogDialog from '../PipelineRunLogDialog'; + +jest.mock('@material-ui/styles', () => ({ + ...jest.requireActual('@material-ui/styles'), + makeStyles: (cb: any) => (theme: Theme) => + cb({ + ...theme, + spacing: () => 0, + palette: { grey: { 500: 'grey' } }, + }), +})); + +jest.mock('@backstage/core-components', () => ({ + ErrorBoundary: (props: any) => <>{props.children}, +})); + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +jest.mock('../PipelineRunLogs', () => () =>
Pipeline run logs
); + +describe('PipelineRunLogDialog', () => { + beforeEach(() => { + (useApi as any).mockReturnValue({ + getPodLogs: jest.fn().mockResolvedValue({ text: 'log data...' }), + }); + }); + it('should not show pipeline run logs modal', () => { + const closeDialog = jest.fn(); + render( + , + ); + + expect( + screen.queryByTestId('pipelinerun-logs-dialog'), + ).not.toBeInTheDocument(); + }); + + it('should show pipeline run logs modal', () => { + const closeDialog = jest.fn(); + render( + , + ); + + expect(screen.getByTestId('pipelinerun-logs-dialog')).toBeInTheDocument(); + }); + + it('should not show download links in the logs modal if there are no pods', () => { + const closeDialog = jest.fn(); + render( + , + ); + + expect(screen.getByTestId('pipelinerun-logs-dialog')).toBeInTheDocument(); + + expect( + screen.queryByTestId('pipelinerun-logs-downloader'), + ).not.toBeInTheDocument(); + }); + + it('should show download links in the logs modal if pods are available', () => { + const closeDialog = jest.fn(); + render( + , + ); + + expect(screen.getByTestId('pipelinerun-logs-dialog')).toBeInTheDocument(); + expect( + screen.queryByTestId('pipelinerun-logs-downloader'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDownloader.test.tsx b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDownloader.test.tsx new file mode 100644 index 0000000000..09fac0b431 --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PipelineRunLogDownloader.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import { testPipelineRunPods } from '../../../__fixtures__/pods-data'; +import PipelineRunLogDownloader from '../PipelineRunLogDownloader'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +describe('PipelineRunLogDownloader', () => { + it('should not show download links', () => { + const { pipelineRun } = testPipelineRunPods; + render( + , + ); + + expect(screen.queryByTestId('download-task-logs')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('download-pipelinerun-logs'), + ).not.toBeInTheDocument(); + }); + + it('should return download links', () => { + const { pipelineRun, pods } = testPipelineRunPods; + render( + , + ); + + expect(screen.getByTestId('download-task-logs')).toBeInTheDocument(); + expect(screen.getByTestId('download-pipelinerun-logs')).toBeInTheDocument(); + }); +}); diff --git a/plugins/tekton/src/components/PipelineRunLogs/__tests__/PodLogsDownloadLink.test.tsx b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PodLogsDownloadLink.test.tsx new file mode 100644 index 0000000000..15636a509d --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunLogs/__tests__/PodLogsDownloadLink.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { downloadLogFile } from '@janus-idp/shared-react'; + +import { testPipelineRunPods } from '../../../__fixtures__/pods-data'; +import { getPodLogs } from '../../../utils/log-downloader-utils'; +import PodLogsDownloadLink from '../PodLogsDownloadLink'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +jest.mock('../../../utils/log-downloader-utils', () => ({ + getPodLogs: jest.fn(), +})); + +jest.mock('@janus-idp/shared-react', () => ({ + ...jest.requireActual('@janus-idp/shared-react'), + downloadLogFile: jest.fn(), +})); + +const getPodLogsMock = getPodLogs as jest.Mock; +const downloadLogFileMock = downloadLogFile as jest.Mock; + +describe('PodLogsDownloadLink', () => { + beforeEach(() => { + getPodLogsMock.mockResolvedValue(''); + downloadLogFileMock.mockResolvedValue('download complete'); + }); + + it('should download the pipelineRun logs', async () => { + const { pods } = testPipelineRunPods; + + render( + , + ); + fireEvent.click(screen.getByTestId('download-pipelinerun-logs')); + + await waitFor(() => { + expect( + screen.getByTestId('download-pipelinerun-logs'), + ).toBeInTheDocument(); + + expect(getPodLogsMock).toHaveBeenCalled(); + expect(downloadLogFileMock).toHaveBeenCalled(); + }); + }); + + it('should disable download button during download and enable it back the process is complete', async () => { + getPodLogsMock.mockResolvedValue(''); + downloadLogFileMock.mockResolvedValue('download complete'); + + const { pods } = testPipelineRunPods; + render( + , + ); + + fireEvent.click(screen.getByTestId('download-pipelinerun-logs')); + + expect(screen.getByTestId('download-pipelinerun-logs')).toHaveAttribute( + 'disabled', + ); + + expect( + screen.getByTestId('download-pipelinerun-logs').getAttribute('title'), + ).toBe('downloading logs'); + + await waitFor(() => { + expect( + screen.getByTestId('download-pipelinerun-logs'), + ).not.toHaveAttribute('disabled'); + expect( + screen.getByTestId('download-pipelinerun-logs').getAttribute('title'), + ).toBe('Download all task logs'); + }); + }); + + it('should re-enable download button incase of any errors during download', async () => { + getPodLogsMock.mockRejectedValue('logs download error'); + downloadLogFileMock.mockResolvedValue(''); + + const { pods } = testPipelineRunPods; + render( + , + ); + + fireEvent.click(screen.getByTestId('download-pipelinerun-logs')); + + expect(screen.getByTestId('download-pipelinerun-logs')).toHaveAttribute( + 'disabled', + ); + + expect( + screen.getByTestId('download-pipelinerun-logs').getAttribute('title'), + ).toBe('downloading logs'); + + await waitFor(() => { + expect( + screen.getByTestId('download-pipelinerun-logs'), + ).not.toHaveAttribute('disabled'); + expect( + screen.getByTestId('download-pipelinerun-logs').getAttribute('title'), + ).toBe('Download all task logs'); + }); + }); +}); diff --git a/plugins/tekton/src/consts/tekton-const.ts b/plugins/tekton/src/consts/tekton-const.ts index 305041a5a0..e7cd977eb1 100644 --- a/plugins/tekton/src/consts/tekton-const.ts +++ b/plugins/tekton/src/consts/tekton-const.ts @@ -1,3 +1,4 @@ export const TEKTON_CI_ANNOTATION = 'janus-idp.io/tekton'; export const TEKTON_PIPELINE_TASK = 'tekton.dev/pipelineTask'; export const TEKTON_PIPELINE_RUN = 'tekton.dev/pipelineRun'; +export const TEKTON_PIPELINE_TASKRUN = 'tekton.dev/taskRun'; diff --git a/plugins/tekton/src/utils/log-downloader-utils.test.ts b/plugins/tekton/src/utils/log-downloader-utils.test.ts new file mode 100644 index 0000000000..5c5458e916 --- /dev/null +++ b/plugins/tekton/src/utils/log-downloader-utils.test.ts @@ -0,0 +1,46 @@ +import { testPods } from '../__fixtures__/pods-data'; +import { ContainerScope } from '../hooks/usePodLogsOfPipelineRun'; +import { getPodLogs } from './log-downloader-utils'; + +describe('getPodLogs', () => { + it('should return empty logs if there are no pods', async () => { + const podLogsGetter = () => Promise.resolve({ text: '' }); + const logs = await getPodLogs([], podLogsGetter, 'cluster-1'); + + expect(logs).toBe(''); + }); + + it('should return logs if there are pods', async () => { + const podLogsGetter = (p: ContainerScope) => { + return Promise.resolve({ text: `${p.containerName}` }); + }; + const logs = await getPodLogs(testPods, podLogsGetter, 'cluster-1'); + + expect(logs).toBe(`STEP-TKN +step-tkn +STEP-PRINT-SBOM-RESULTS +step-print-sbom-results`); + }); + + it('should display logs only for the pods that has logs', async () => { + const podLogsGetter = (p: ContainerScope) => { + return Promise.resolve({ text: `${p.containerName}` }); + }; + + const podsWithoutContainers = [ + testPods[0], + + { ...testPods[1], spec: { ...testPods[1].spec, containers: [] } }, + ]; + + const logs = await getPodLogs( + podsWithoutContainers, + podLogsGetter, + 'cluster-1', + ); + + expect(logs).toBe(`STEP-TKN +step-tkn +`); + }); +}); diff --git a/plugins/tekton/src/utils/log-downloader-utils.ts b/plugins/tekton/src/utils/log-downloader-utils.ts new file mode 100644 index 0000000000..eedf08902c --- /dev/null +++ b/plugins/tekton/src/utils/log-downloader-utils.ts @@ -0,0 +1,45 @@ +import { V1Container, V1Pod } from '@kubernetes/client-node'; + +import { ContainerScope } from '../hooks/usePodLogsOfPipelineRun'; + +export async function getPodLogs( + pods: V1Pod[] | [], + podLogsGetter: (podScope: ContainerScope) => Promise<{ text: string }>, + currentClusterName: string, +): Promise { + const containersList = pods.map((pod: V1Pod) => pod?.spec?.containers ?? []); + const isPodAndContainerAvailable = ( + pod: V1Pod, + container: V1Container, + ): boolean => !!(pod && container); + + const requests: Promise<{ text: string }>[] = []; + containersList.forEach((containers: V1Container[], _idx: number) => { + containers.forEach((container: V1Container) => { + const pod: V1Pod = pods[_idx]; + if (isPodAndContainerAvailable(pod, container)) { + const podScope: ContainerScope = { + containerName: container.name, + podName: pod.metadata?.name ?? '', + podNamespace: pod.metadata?.namespace ?? '', + clusterName: currentClusterName, + }; + + requests.push(podLogsGetter(podScope)); + } + }); + }); + return Promise.all(requests).then(response => { + const containerFlatList = containersList.flat(1); + return response.reduce( + (acc: string, r: { text: string }, idx) => { + const container: V1Container = containerFlatList[idx]; + return acc + .concat(`${container?.name.toUpperCase()}\n${r?.text}`) + .concat(idx === containersList.length - 1 ? '' : '\n'); + }, + + '', + ); + }); +} diff --git a/plugins/topology/package.json b/plugins/topology/package.json index eaae194de3..a36254bbee 100644 --- a/plugins/topology/package.json +++ b/plugins/topology/package.json @@ -45,7 +45,6 @@ "@patternfly/react-tokens": "^5.1.1", "@patternfly/react-topology": "^5.1.0", "classnames": "2.x", - "file-saver": "^2.0.5", "git-url-parse": "^13.1.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -65,7 +64,6 @@ "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "8.0.1", "@testing-library/user-event": "14.5.1", - "@types/file-saver": "2.0.6", "@types/git-url-parse": "9.0.2", "@types/node": "18.18.5", "cross-fetch": "4.0.0", diff --git a/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.test.tsx b/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.test.tsx index b2553dd88b..0ef2b51480 100644 --- a/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.test.tsx +++ b/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.test.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { downloadLogFile } from '../../../../utils/pod-log-utils'; +import { downloadLogFile } from '@janus-idp/shared-react'; + import PodLogsDownload from './PodLogsDownload'; jest.mock('@material-ui/core', () => ({ @@ -14,7 +15,7 @@ jest.mock('@material-ui/core', () => ({ jest.mock('@material-ui/icons/GetApp', () => () =>
DownloadIcon
); -jest.mock('../../../../utils/pod-log-utils', () => ({ +jest.mock('@janus-idp/shared-react', () => ({ downloadLogFile: jest.fn(), })); diff --git a/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.tsx b/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.tsx index 167b2afb01..2a81aa609f 100644 --- a/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.tsx +++ b/plugins/topology/src/components/Topology/TopologySideBar/PodLogs/PodLogsDownload.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { IconButton } from '@material-ui/core'; import DownloadIcon from '@material-ui/icons/GetApp'; -import { downloadLogFile } from '../../../../utils/pod-log-utils'; +import { downloadLogFile } from '@janus-idp/shared-react'; type PodLogsDownloadProps = { logText?: string;