diff --git a/src/components/Components/ComponentsListView/ComponentListView.tsx b/src/components/Components/ComponentsListView/ComponentListView.tsx index 5e1eb582..d41db6ed 100644 --- a/src/components/Components/ComponentsListView/ComponentListView.tsx +++ b/src/components/Components/ComponentsListView/ComponentListView.tsx @@ -62,6 +62,7 @@ const ComponentListView: React.FC { try { + const pid = getPipelineRunFromTaskRunOwnerRef(taskRun[0])?.uid; const logs = await getTaskRunLog( workspace, taskRun[0].metadata.namespace, - taskRun[0].metadata.name, + taskRun[0].metadata.uid, + pid, ); if (unmount) return; const json = extractEcResultsFromTaskRunLogs(logs); diff --git a/src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx b/src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx index 1aa24313..a24a6b3c 100644 --- a/src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx +++ b/src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx @@ -16,8 +16,6 @@ import { Bullseye, Spinner, } from '@patternfly/react-core'; -// import { ErrorDetailsWithStaticLog } from '../../../shared/components/pipeline-run-logs/logs/log-snippet-types'; -// import { getPLRLogSnippet } from '../../../shared/components/pipeline-run-logs/logs/pipelineRunLogSnippet'; import { PipelineRunLabel } from '../../../../consts/pipelinerun'; import { usePipelineRun } from '../../../../hooks/usePipelineRuns'; import { useTaskRuns } from '../../../../hooks/useTaskRuns'; @@ -27,6 +25,8 @@ import { RouterParams } from '../../../../routes/utils'; import { Timestamp } from '../../../../shared'; import ErrorEmptyState from '../../../../shared/components/empty-state/ErrorEmptyState'; import ExternalLink from '../../../../shared/components/links/ExternalLink'; +import { ErrorDetailsWithStaticLog } from '../../../../shared/components/pipeline-run-logs/logs/log-snippet-types'; +import { getPLRLogSnippet } from '../../../../shared/components/pipeline-run-logs/logs/pipelineRunLogSnippet'; import { getCommitSha, getCommitShortName } from '../../../../utils/commits-utils'; import { calculateDuration, @@ -82,8 +82,8 @@ const PipelineRunDetailsTab: React.FC = () => { ); } const results = getPipelineRunStatusResults(pipelineRun); - const pipelineRunFailed = {} as { title: string; staticMessage: string }; // (getPLRLogSnippet(pipelineRun, taskRuns) || - //{}) as ErrorDetailsWithStaticLog; + const pipelineRunFailed = (getPLRLogSnippet(pipelineRun, taskRuns) || + {}) as ErrorDetailsWithStaticLog; const duration = calculateDuration( typeof pipelineRun.status?.startTime === 'string' ? pipelineRun.status?.startTime : '', typeof pipelineRun.status?.completionTime === 'string' diff --git a/src/hooks/__tests__/useTektonResults.spec.tsx b/src/hooks/__tests__/useTektonResults.spec.tsx index bf7fb295..4ce4ac91 100644 --- a/src/hooks/__tests__/useTektonResults.spec.tsx +++ b/src/hooks/__tests__/useTektonResults.spec.tsx @@ -2,6 +2,8 @@ // import { QueryClientProvider } from '@tanstack/react-query'; // import { act, renderHook as rtlRenderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks'; +import { PipelineRunModel } from '../../models'; +import { TaskRunKind } from '../../types'; import { // TektonResultsOptions, getPipelineRuns, @@ -180,6 +182,16 @@ describe('useTektonResults', () => { }); }); + const mockTR = { + metadata: { + name: 'sample-task-run', + uid: 'sample-task-run-id', + ownerReferences: [ + { kind: PipelineRunModel.kind, uid: 'sample-pipeline-run-id', name: 'sample-pipeline-run' }, + ], + }, + } as TaskRunKind; + describe('useTRTaskRunLog', () => { it('should not attempt to get task run log', () => { renderHook(() => useTRTaskRunLog(null, null)); @@ -188,14 +200,19 @@ describe('useTektonResults', () => { renderHook(() => useTRTaskRunLog('test-ns', null)); expect(getTaskRunLogMock).not.toHaveBeenCalled(); - renderHook(() => useTRTaskRunLog(null, 'sample-task-run')); + renderHook(() => useTRTaskRunLog(null, mockTR)); expect(getTaskRunLogMock).not.toHaveBeenCalled(); }); it('should return task run log', async () => { getTaskRunLogMock.mockReturnValue('sample log'); - const { result, waitFor } = renderHook(() => useTRTaskRunLog('test-ns', 'sample-task-run')); - expect(getTaskRunLogMock).toHaveBeenCalledWith('test-ws', 'test-ns', 'sample-task-run'); + const { result, waitFor } = renderHook(() => useTRTaskRunLog('test-ns', mockTR)); + expect(getTaskRunLogMock).toHaveBeenCalledWith( + 'test-ws', + 'test-ns', + 'sample-task-run-id', + 'sample-pipeline-run-id', + ); expect(result.current).toEqual([null, false, undefined]); await waitFor(() => result.current[1]); expect(result.current).toEqual(['sample log', true, undefined]); @@ -206,8 +223,13 @@ describe('useTektonResults', () => { getTaskRunLogMock.mockImplementation(() => { throw error; }); - const { result } = renderHook(() => useTRTaskRunLog('test-ns', 'sample-task-run')); - expect(getTaskRunLogMock).toHaveBeenCalledWith('test-ws', 'test-ns', 'sample-task-run'); + const { result } = renderHook(() => useTRTaskRunLog('test-ns', mockTR)); + expect(getTaskRunLogMock).toHaveBeenCalledWith( + 'test-ws', + 'test-ns', + 'sample-task-run-id', + 'sample-pipeline-run-id', + ); expect(result.current).toEqual([null, false, error]); }); }); diff --git a/src/hooks/useApplicationReleases.ts b/src/hooks/useApplicationReleases.ts index 92d7ef37..d365f7a5 100644 --- a/src/hooks/useApplicationReleases.ts +++ b/src/hooks/useApplicationReleases.ts @@ -19,6 +19,7 @@ export const useApplicationReleases = ( namespace, workspace, isList: true, + watch: true, }, ReleaseModel, ); diff --git a/src/hooks/useComponents.ts b/src/hooks/useComponents.ts index 1c5e6a9e..1c867af4 100644 --- a/src/hooks/useComponents.ts +++ b/src/hooks/useComponents.ts @@ -38,6 +38,7 @@ export const useComponents = ( namespace: string, workspace: string, applicationName: string, + watch?: boolean, ): [ComponentKind[], boolean, unknown] => { const { data: components, @@ -49,6 +50,7 @@ export const useComponents = ( workspace, namespace, isList: true, + watch, }, ComponentModel, ); diff --git a/src/hooks/useReleases.ts b/src/hooks/useReleases.ts index 9f1af356..e1bb6523 100644 --- a/src/hooks/useReleases.ts +++ b/src/hooks/useReleases.ts @@ -12,6 +12,7 @@ export const useReleases = ( namespace, workspace, isList: true, + watch: true, }, ReleaseModel, ); @@ -29,6 +30,7 @@ export const useRelease = ( namespace, workspace, name, + watch: true, }, ReleaseModel, ); diff --git a/src/hooks/useTektonResults.ts b/src/hooks/useTektonResults.ts index 3192273b..24f8e3f3 100644 --- a/src/hooks/useTektonResults.ts +++ b/src/hooks/useTektonResults.ts @@ -2,6 +2,7 @@ import React from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useWorkspaceInfo } from '../components/Workspace/useWorkspaceInfo'; import { PipelineRunKind, TaskRunKind } from '../types'; +import { getPipelineRunFromTaskRunOwnerRef } from '../utils/common-utils'; import { TektonResultsOptions, getTaskRunLog, @@ -61,16 +62,18 @@ export const useTRTaskRuns = ( export const useTRTaskRunLog = ( namespace: string, - taskRunName: string, + taskRun: TaskRunKind, ): [string, boolean, unknown] => { const { workspace } = useWorkspaceInfo(); const [result, setResult] = React.useState<[string, boolean, unknown]>([null, false, undefined]); + const taskRunUid = taskRun.metadata.uid; + const pipelineRunUid = getPipelineRunFromTaskRunOwnerRef(taskRun)?.uid; React.useEffect(() => { let disposed = false; - if (namespace && taskRunName) { + if (namespace && taskRunUid) { void (async () => { try { - const log = await getTaskRunLog(workspace, namespace, taskRunName); + const log = await getTaskRunLog(workspace, namespace, taskRunUid, pipelineRunUid); if (!disposed) { setResult([log, true, undefined]); } @@ -84,6 +87,6 @@ export const useTRTaskRunLog = ( return () => { disposed = true; }; - }, [workspace, namespace, taskRunName]); + }, [workspace, namespace, taskRunUid, pipelineRunUid]); return result; }; diff --git a/src/k8s/hooks/__tests__/useK8sQueryWatch.spec.ts b/src/k8s/hooks/__tests__/useK8sQueryWatch.spec.ts new file mode 100644 index 00000000..751cfa82 --- /dev/null +++ b/src/k8s/hooks/__tests__/useK8sQueryWatch.spec.ts @@ -0,0 +1,178 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { K8sModelCommon } from '../../../types/k8s'; +import { watchListResource, watchObjectResource } from '../../watch-utils'; +import { useK8sQueryWatch } from '../useK8sQueryWatch'; + +jest.mock('../../watch-utils', () => ({ + watchListResource: jest.fn(), + watchObjectResource: jest.fn(), +})); + +const WEBSOCKET_RETRY_COUNT = 3; +const WEBSOCKET_RETRY_DELAY = 2000; + +describe('useK8sQueryWatch', () => { + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + // Clear the WS Map + (global as unknown as { WS: unknown }).WS = new Map(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const mockWebSocket = { + destroy: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + }; + + const mockResourceInit = { + model: { kind: 'Test', apiGroup: 'test.group', apiVersion: 'v1' } as K8sModelCommon, + queryOptions: {}, + }; + + const mockOptions = { wsPrefix: '/test' }; + const mockHashedKey = 'test-key'; + + it('should initialize websocket for list resource', () => { + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + renderHook(() => useK8sQueryWatch(mockResourceInit, true, mockHashedKey, mockOptions)); + + expect(watchListResource).toHaveBeenCalledWith(mockResourceInit, mockOptions); + expect(watchObjectResource).not.toHaveBeenCalled(); + }); + + it('should initialize websocket for single resource', () => { + (watchObjectResource as jest.Mock).mockReturnValue(mockWebSocket); + + renderHook(() => useK8sQueryWatch(mockResourceInit, false, mockHashedKey, mockOptions)); + + expect(watchObjectResource).toHaveBeenCalledWith(mockResourceInit, mockOptions); + expect(watchListResource).not.toHaveBeenCalled(); + }); + + it('should not initialize websocket when resourceInit is null', () => { + renderHook(() => useK8sQueryWatch(null, true, mockHashedKey, mockOptions)); + + expect(watchListResource).not.toHaveBeenCalled(); + expect(watchObjectResource).not.toHaveBeenCalled(); + }); + + it('should clean up websocket on unmount', () => { + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + const { unmount } = renderHook(() => + useK8sQueryWatch(mockResourceInit, true, mockHashedKey, mockOptions), + ); + + unmount(); + + expect(mockWebSocket.destroy).toHaveBeenCalled(); + }); + + it('should handle websocket close with code 1006 and attempt reconnection', () => { + jest.useFakeTimers(); + let closeHandler: (event: { code: number }) => void; + + mockWebSocket.onClose.mockImplementation((handler) => { + closeHandler = handler; + }); + + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + renderHook(() => useK8sQueryWatch(mockResourceInit, true, mockHashedKey, mockOptions)); + + // Simulate websocket close with code 1006 + act(() => { + closeHandler({ code: 1006 }); + }); + + // First retry + act(() => { + jest.advanceTimersByTime(WEBSOCKET_RETRY_DELAY); + }); + + expect(watchListResource).toHaveBeenCalledTimes(2); + }); + + it('should set error state after max retry attempts', () => { + jest.useFakeTimers(); + let closeHandler: (event: { code: number }) => void; + + mockWebSocket.onClose.mockImplementation((handler) => { + closeHandler = handler; + }); + + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + const { result } = renderHook(() => + useK8sQueryWatch(mockResourceInit, true, mockHashedKey, mockOptions), + ); + + // Simulate multiple websocket closes + for (let i = 0; i <= WEBSOCKET_RETRY_COUNT; i++) { + act(() => { + closeHandler({ code: 1006 }); + // Advance time by retry delay with exponential backoff + jest.advanceTimersByTime(WEBSOCKET_RETRY_DELAY * Math.pow(2, i)); + }); + } + + expect(result.current).toEqual({ + code: 1006, + message: 'WebSocket connection failed after multiple attempts', + }); + }); + + it('should handle websocket errors', () => { + let errorHandler: (error: { code: number; message: string }) => void; + + mockWebSocket.onError.mockImplementation((handler) => { + errorHandler = handler; + }); + + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + const { result } = renderHook(() => + useK8sQueryWatch(mockResourceInit, true, mockHashedKey, mockOptions), + ); + + const mockError = { code: 1011, message: 'Test error' }; + + act(() => { + errorHandler(mockError); + }); + + expect(result.current).toEqual(mockError); + }); + + it('should clear error state and retry count on new resourceInit', () => { + (watchListResource as jest.Mock).mockReturnValue(mockWebSocket); + + const { rerender, result } = renderHook( + ({ resourceInit }) => useK8sQueryWatch(resourceInit, true, mockHashedKey, mockOptions), + { initialProps: { resourceInit: mockResourceInit } }, + ); + + // Set error state + act(() => { + mockWebSocket.onError.mock.calls[0][0]({ code: 1011, message: 'Test error' }); + }); + + expect(result.current).toBeTruthy(); + + // Rerender with new resourceInit + rerender({ + resourceInit: { + ...mockResourceInit, + model: { kind: 'TestNew', apiGroup: 'test.group', apiVersion: 'v1' } as K8sModelCommon, + }, + }); + + expect(result.current).toBeNull(); + }); +}); diff --git a/src/k8s/hooks/useK8sQueryWatch.ts b/src/k8s/hooks/useK8sQueryWatch.ts index 964e024d..91d1002f 100644 --- a/src/k8s/hooks/useK8sQueryWatch.ts +++ b/src/k8s/hooks/useK8sQueryWatch.ts @@ -6,6 +6,9 @@ import { WebSocketOptions } from '../web-socket/types'; const WS = new Map(); +const WEBSOCKET_RETRY_COUNT = 3; +const WEBSOCKET_RETRY_DELAY = 2000; + export const useK8sQueryWatch = ( resourceInit: K8sResourceBaseOptions, isList: boolean, @@ -14,22 +17,79 @@ export const useK8sQueryWatch = ( ) => { const deepResourceInit = useDeepCompareMemoize(resourceInit); const deepOptions = useDeepCompareMemoize(options); - React.useEffect(() => { - const startWatch = () => { - if (deepResourceInit && !WS.has(hashedKey)) { - const websocket = isList - ? watchListResource(deepResourceInit, deepOptions) - : watchObjectResource(deepResourceInit, deepOptions); - WS.set(hashedKey, websocket); + const retryCountRef = React.useRef(0); + const retryTimeoutRef = React.useRef(); + const [wsError, setWsError] = React.useState<{ code: number; message: string } | null>(null); + + const startWatch = React.useCallback(() => { + if (!deepResourceInit) return; + + if (WS.has(hashedKey)) { + WS.get(hashedKey).destroy(); + WS.delete(hashedKey); + } + + const websocket = isList + ? watchListResource(deepResourceInit, deepOptions) + : watchObjectResource(deepResourceInit, deepOptions); + + websocket.onClose((event) => { + if (event.code === 1006) { + // eslint-disable-next-line no-console + console.warn(`WebSocket closed unexpectedly for ${hashedKey}. Attempting reconnect...`); + + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } + + if (retryCountRef.current < WEBSOCKET_RETRY_COUNT) { + retryTimeoutRef.current = setTimeout( + () => { + retryCountRef.current += 1; + startWatch(); + }, + WEBSOCKET_RETRY_DELAY * Math.pow(2, retryCountRef.current), + ); // Exponential backoff + } else { + // eslint-disable-next-line no-console + console.error( + `Failed to reconnect WebSocket for ${hashedKey} after ${WEBSOCKET_RETRY_COUNT} attempts`, + ); + setWsError({ + code: event.code, + message: 'WebSocket connection failed after multiple attempts', + }); + retryCountRef.current = 0; + } } - }; + }); + + websocket.onError((error) => { + // eslint-disable-next-line no-console + console.error(`WebSocket error for ${hashedKey}:`, error); + setWsError({ + code: (error as unknown as { code: number }).code || 0, + message: (error as unknown as { message: string }).message || 'Unknown WebSocket error', + }); + }); + + WS.set(hashedKey, websocket); + }, [deepResourceInit, deepOptions, hashedKey, isList]); + React.useEffect(() => { startWatch(); return () => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } if (deepResourceInit && WS.has(hashedKey)) { WS.get(hashedKey).destroy(); WS.delete(hashedKey); } + retryCountRef.current = 0; + setWsError(null); }; - }, [isList, hashedKey, deepOptions, deepResourceInit]); + }, [hashedKey, deepOptions, deepResourceInit, startWatch]); + + return wsError; }; diff --git a/src/k8s/hooks/useK8sWatchResource.ts b/src/k8s/hooks/useK8sWatchResource.ts index 2c4a075d..a51bb65a 100644 --- a/src/k8s/hooks/useK8sWatchResource.ts +++ b/src/k8s/hooks/useK8sWatchResource.ts @@ -12,6 +12,8 @@ import { createGetQueryOptions, createListqueryOptions, createQueryKeys } from ' import { WebSocketOptions } from '../web-socket/types'; import { useK8sQueryWatch } from './useK8sQueryWatch'; +const POLLING_INTERVAL = 10000; + export const useK8sWatchResource = ( resourceInit: WatchK8sResource, model: K8sModelCommon, @@ -21,7 +23,7 @@ export const useK8sWatchResource = = {}, ): UseQueryResult => { const k8sQueryOptions = convertToK8sQueryParams(resourceInit); - useK8sQueryWatch( + const wsError = useK8sQueryWatch( resourceInit?.watch ? { model, queryOptions: k8sQueryOptions } : null, resourceInit?.isList, hashKey(createQueryKeys({ model, queryOptions: k8sQueryOptions })), @@ -32,16 +34,21 @@ export const useK8sWatchResource = , 'queryKey' | 'queryFn'>); + const baseQueryOptions = { + enabled: !!resourceInit, + refetchInterval: wsError ? POLLING_INTERVAL : undefined, + ...queryOptionsTyped, + }; return ( resourceInit?.isList - ? createListqueryOptions({ model, queryOptions: k8sQueryOptions, fetchOptions: options }, { - enabled: !!resourceInit, - ...queryOptionsTyped, - } as TQueryOptions) - : createGetQueryOptions({ model, queryOptions: k8sQueryOptions, fetchOptions: options }, { - enabled: !!resourceInit, - ...queryOptionsTyped, - } as Omit, 'queryKey' | 'queryFn'>) + ? createListqueryOptions( + { model, queryOptions: k8sQueryOptions, fetchOptions: options }, + baseQueryOptions as TQueryOptions, + ) + : createGetQueryOptions( + { model, queryOptions: k8sQueryOptions, fetchOptions: options }, + baseQueryOptions as Omit, 'queryKey' | 'queryFn'>, + ) ) as UseQueryOptions; }; diff --git a/src/k8s/k8s-utils.ts b/src/k8s/k8s-utils.ts index da57239c..35c2c61d 100644 --- a/src/k8s/k8s-utils.ts +++ b/src/k8s/k8s-utils.ts @@ -176,7 +176,10 @@ export const getK8sResourceURL = ( ? pick(queryParams, FILTERED_CREATE_QUERY_PARAMS) : queryParams; if (queryOptions?.queryParams?.labelSelector) { - filteredQueryParams.labelSelector = selectorToString(queryOptions.queryParams.labelSelector); + filteredQueryParams.labelSelector = + typeof queryOptions.queryParams.labelSelector !== 'string' + ? selectorToString(queryOptions.queryParams.labelSelector) + : queryOptions.queryParams.labelSelector; } if (filteredQueryParams && !isEmpty(filteredQueryParams)) { @@ -215,7 +218,8 @@ export const k8sWatch = ( const { labelSelector } = query; if (labelSelector) { - queryParams.labelSelector = { ...labelSelector }; + queryParams.labelSelector = + typeof labelSelector === 'string' ? labelSelector : { ...labelSelector }; } if (query.fieldSelector) { diff --git a/src/shared/components/pipeline-run-logs/logs/TektonTaskRunLog.tsx b/src/shared/components/pipeline-run-logs/logs/TektonTaskRunLog.tsx index 290a16e1..a95fa349 100644 --- a/src/shared/components/pipeline-run-logs/logs/TektonTaskRunLog.tsx +++ b/src/shared/components/pipeline-run-logs/logs/TektonTaskRunLog.tsx @@ -19,10 +19,7 @@ export const TektonTaskRunLog: React.FC { const scrollPane = React.useRef(); const taskName = taskRun?.spec.taskRef?.name ?? taskRun?.metadata.name; - const [trResults, trLoaded, trError] = useTRTaskRunLog( - taskRun.metadata.namespace, - taskRun.metadata.name, - ); + const [trResults, trLoaded, trError] = useTRTaskRunLog(taskRun.metadata.namespace, taskRun); React.useEffect(() => { setCurrentLogsGetter(() => scrollPane.current?.innerText); diff --git a/src/shared/components/pipeline-run-logs/logs/logs-utils.ts b/src/shared/components/pipeline-run-logs/logs/logs-utils.ts index e16a1014..1d437e44 100644 --- a/src/shared/components/pipeline-run-logs/logs/logs-utils.ts +++ b/src/shared/components/pipeline-run-logs/logs/logs-utils.ts @@ -2,6 +2,7 @@ import { saveAs } from 'file-saver'; import { commonFetchText } from '../../../../k8s'; import { K8sGetResource } from '../../../../k8s/k8s-fetch'; import { getK8sResourceURL } from '../../../../k8s/k8s-utils'; +import { PipelineRunModel } from '../../../../models'; import { PodModel } from '../../../../models/pod'; import { TaskRunKind } from '../../../../types'; import { getTaskRunLog } from '../../../../utils/tekton-results'; @@ -146,9 +147,16 @@ export const getDownloadAllLogsCallback = ( ]); } } else { - allLogs += await getTaskRunLog(workspace, namespace, currTask).then( - (log) => `${tasks[currTask].name.toUpperCase()}\n\n${log}\n\n`, - ); + const taskRun = taskRuns.find((t) => t.metadata.name === currTask); + const pipelineRunUID = taskRun?.metadata?.ownerReferences?.find( + (res) => res.kind === PipelineRunModel.kind, + )?.uid; + allLogs += await getTaskRunLog( + workspace, + namespace, + pipelineRunUID, + taskRun?.metadata?.uid, + ).then((log) => `${tasks[currTask].name.toUpperCase()}\n\n${log}\n\n`); } } const buffer = new LineBuffer(); diff --git a/src/utils/__tests__/tekton-results.spec.ts b/src/utils/__tests__/tekton-results.spec.ts index f35584fc..327b1151 100644 --- a/src/utils/__tests__/tekton-results.spec.ts +++ b/src/utils/__tests__/tekton-results.spec.ts @@ -348,11 +348,18 @@ describe('tekton-results', () => { describe('createTektonResultsUrl', () => { it('should create minimal URL', () => { - expect(createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun)).toEqual( - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.PipelineRun%22', + expect( + createTektonResultsUrl('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]), + ).toEqual( + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+in+%5B%22tekton.dev%2Fv1.PipelineRun%22%2C%22tekton.dev%2Fv1beta1.PipelineRun%22%5D', ); - expect(createTektonResultsUrl('test-ws', 'test-ns', DataType.TaskRun)).toEqual( - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.TaskRun%22', + expect( + createTektonResultsUrl('test-ws', 'test-ns', [DataType.TaskRun, DataType.TaskRun_v1beta1]), + ).toEqual( + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+in+%5B%22tekton.dev%2Fv1.TaskRun%22%2C%22tekton.dev%2Fv1beta1.TaskRun%22%5D', ); }); @@ -361,7 +368,7 @@ describe('tekton-results', () => { createTektonResultsUrl( 'test-ws', 'test-ns', - DataType.PipelineRun, + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], null, null, 'test-token', @@ -370,37 +377,65 @@ describe('tekton-results', () => { }); it('should create URL with filter', () => { - expect(createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun, 'foo=bar')).toEqual( - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.PipelineRun%22+%26%26+foo%3Dbar', + expect( + createTektonResultsUrl( + 'test-ws', + 'test-ns', + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], + 'foo=bar', + ), + ).toEqual( + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+in+%5B%22tekton.dev%2Fv1.PipelineRun%22%2C%22tekton.dev%2Fv1beta1.PipelineRun%22%5D+%26%26+foo%3Dbar', ); }); it('should create URL with page size', () => { // default page size - expect(createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun)).toContain( - 'page_size=30', - ); + expect( + createTektonResultsUrl('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]), + ).toContain('page_size=30'); // min page size expect( - createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun, '', { - pageSize: 1, - }), + createTektonResultsUrl( + 'test-ws', + 'test-ns', + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], + '', + { + pageSize: 1, + }, + ), ).toContain('page_size=5'); // min page size expect( - createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun, '', { - pageSize: 11000, - }), + createTektonResultsUrl( + 'test-ws', + 'test-ns', + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], + '', + { + pageSize: 11000, + }, + ), ).toContain('page_size=10000'); }); it('should create URL using limit to affect page size', () => { expect( - createTektonResultsUrl('test-ws', 'test-ns', DataType.PipelineRun, '', { - pageSize: 10, - limit: 5, - }), + createTektonResultsUrl( + 'test-ws', + 'test-ns', + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], + '', + { + pageSize: 10, + limit: 5, + }, + ), ).toContain('page_size=5'); }); @@ -409,12 +444,12 @@ describe('tekton-results', () => { createTektonResultsUrl( 'test-ws', 'test-ns', - DataType.PipelineRun, + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], 'foo=bar', sampleOptions, ), ).toContain( - 'filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.PipelineRun%22+%26%26+foo%3Dbar+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&filter=data_type+in+%5B%22tekton.dev%2Fv1.PipelineRun%22%2C%22tekton.dev%2Fv1beta1.PipelineRun%22%5D+%26%26+foo%3Dbar+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', ); }); }); @@ -423,8 +458,14 @@ describe('tekton-results', () => { it('should return cached value', async () => { commonFetchJSONMock.mockReturnValue(mockEmptyRecordsList); - await getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun); - await getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun); + await getFilteredRecord('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]); + await getFilteredRecord('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]); expect(commonFetchJSONMock).toHaveBeenCalledTimes(2); commonFetchJSONMock.mockClear(); @@ -432,7 +473,7 @@ describe('tekton-results', () => { await getFilteredRecord( 'test-ws', 'test-ns', - DataType.PipelineRun, + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], null, null, null, @@ -441,7 +482,7 @@ describe('tekton-results', () => { await getFilteredRecord( 'test-ws', 'test-ns', - DataType.PipelineRun, + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], null, null, null, @@ -456,7 +497,10 @@ describe('tekton-results', () => { code: 404, }; }); - const result = await getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun); + const result = await getFilteredRecord('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]); expect(result).toEqual([[], { nextPageToken: null, records: [] }]); }); @@ -467,21 +511,33 @@ describe('tekton-results', () => { }; }); await expect( - getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun), + getFilteredRecord('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]), ).rejects.toBeTruthy(); }); it('should return record list and decoded value', async () => { commonFetchJSONMock.mockReturnValue(mockRecordsList); - expect(await getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun)).toEqual( - mockResponseCheck, - ); + expect( + await getFilteredRecord('test-ws', 'test-ns', [ + DataType.PipelineRun, + DataType.PipelineRun_v1beta1, + ]), + ).toEqual(mockResponseCheck); }); it('should return record list and decoded value', async () => { commonFetchJSONMock.mockReturnValue(mockRecordsList); expect( - await getFilteredRecord('test-ws', 'test-ns', DataType.PipelineRun, null, { limit: 1 }), + await getFilteredRecord( + 'test-ws', + 'test-ns', + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], + null, + { limit: 1 }, + ), ).toEqual([ [mockResponseCheck[0][0]], { @@ -501,7 +557,7 @@ describe('tekton-results', () => { it('should query tekton results with options', async () => { await getPipelineRuns('test-ws', 'test-ns', sampleOptions, 'test-token'); expect(commonFetchJSONMock).toHaveBeenCalledWith( - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&page_token=test-token&filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.PipelineRun%22+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&page_token=test-token&filter=data_type+in+%5B%22tekton.dev%2Fv1.PipelineRun%22%2C%22tekton.dev%2Fv1beta1.PipelineRun%22%5D+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', ); }); }); @@ -515,7 +571,7 @@ describe('tekton-results', () => { it('should query tekton results with options', async () => { await getTaskRuns('test-ws', 'test-ns', sampleOptions, 'test-token'); expect(commonFetchJSONMock).toHaveBeenCalledWith( - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&page_token=test-token&filter=data_type+%3D%3D+%22tekton.dev%2Fv1beta1.TaskRun%22+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=30&page_token=test-token&filter=data_type+in+%5B%22tekton.dev%2Fv1.TaskRun%22%2C%22tekton.dev%2Fv1beta1.TaskRun%22%5D+%26%26+data.metadata.labels%5B%22test%22%5D+%3D%3D+%22a%22+%26%26+data.metadata.labels%5B%22mtest%22%5D+%3D%3D+%22ma%22+%26%26+count+%3E+1', ); }); }); @@ -524,22 +580,19 @@ describe('tekton-results', () => { it('should return the latest component build task run', async () => { commonFetchJSONMock.mockReturnValueOnce(mockLogsRecordsList); commonFetchTextMock.mockReturnValueOnce(Promise.resolve(mockLogResponse)); - expect(await getTaskRunLog('test-ws', 'test-ns', 'sample-task-run')).toEqual('sample log'); - expect(commonFetchJSONMock.mock.calls).toEqual([ - [ - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/-/records?order_by=create_time+desc&page_size=5&filter=data_type+%3D%3D+%22results.tekton.dev%2Fv1alpha2.Log%22+%26%26+data.spec.resource.kind+%3D%3D+%22TaskRun%22+%26%26+data.spec.resource.name+%3D%3D+%22sample-task-run%22', - ], - ]); + expect(await getTaskRunLog('test-ws', 'test-ns', 'pipelinerun-uid', 'test-id')).toEqual( + 'sample log', + ); expect(commonFetchTextMock.mock.calls).toEqual([ [ - '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/b9f43742-3675-4a71-8d73-31c5f5080a74/logs/113298cc-07f9-3ce0-85e3-5cf635eacf62', + '/plugins/tekton-results/workspaces/test-ws/apis/results.tekton.dev/v1alpha2/parents/test-ns/results/pipelinerun-uid/logs/test-id', ], ]); }); it('should throw error 404 if record not found', async () => { - commonFetchJSONMock.mockReturnValue(mockEmptyRecordsList); - await expect(getTaskRunLog('test-ws', 'test-ns', 'sample-task-run')).rejects.toEqual({ + commonFetchTextMock.mockClear().mockRejectedValue(mockEmptyRecordsList); + await expect(getTaskRunLog('test-ws', 'test-ns', 'sample-task-run', 'test')).rejects.toEqual({ code: 404, }); }); diff --git a/src/utils/common-utils.tsx b/src/utils/common-utils.tsx new file mode 100644 index 00000000..85c86a8d --- /dev/null +++ b/src/utils/common-utils.tsx @@ -0,0 +1,13 @@ +import { curry } from 'lodash-es'; +import { PipelineRunModel } from '../models'; +import { K8sModelCommon, K8sResourceCommon, OwnerReference } from '../types/k8s'; + +export const getResourceFromOwnerReference = curry( + (model: K8sModelCommon, resource: K8sResourceCommon): OwnerReference => { + return resource + ? resource.metadata.ownerReferences?.find((res) => res.kind === model.kind) + : undefined; + }, +); + +export const getPipelineRunFromTaskRunOwnerRef = getResourceFromOwnerReference(PipelineRunModel); diff --git a/src/utils/tekton-results.ts b/src/utils/tekton-results.ts index 32589a78..8b957715 100644 --- a/src/utils/tekton-results.ts +++ b/src/utils/tekton-results.ts @@ -72,13 +72,20 @@ export const OR = (...expressions: string[]) => { const EXP = (left: string, right: string, operator: string) => `${left} ${operator} ${right}`; export const EQ = (left: string, right: string) => EXP(left, `"${right}"`, '=='); export const NEQ = (left: string, right: string) => EXP(left, `"${right}"`, '!='); +export const IN = (left: string, right: string[]) => { + const rightOperands = right.map((operand) => `"${operand.toString()}"`); + return EXP(left, `[${rightOperands.join(',')}]`, 'in'); +}; // TODO: switch to v1 once API is ready // https://github.com/tektoncd/community/pull/1055 export enum DataType { - PipelineRun = 'tekton.dev/v1beta1.PipelineRun', - TaskRun = 'tekton.dev/v1beta1.TaskRun', - Log = 'results.tekton.dev/v1alpha2.Log', + PipelineRun = 'tekton.dev/v1.PipelineRun', + TaskRun = 'tekton.dev/v1.TaskRun', + Log = 'results.tekton.dev/v1alpha3.Log', + PipelineRun_v1beta1 = 'tekton.dev/v1beta1.PipelineRun', + TaskRun_v1beta1 = 'tekton.dev/v1beta1.TaskRun', + Log_v1alpha2 = 'results.tekton.dev/v1alpha2.Log', } export const labelsToFilter = (labels?: MatchLabels): string => @@ -191,7 +198,7 @@ const getTRUrlPrefix = (workspace: string): string => URL_PREFIX.replace(_WORKSP export const createTektonResultsUrl = ( workspace: string, namespace: string, - dataType: DataType, + dataTypes: DataType[], filter?: string, options?: TektonResultsOptions, nextPageToken?: string, @@ -205,7 +212,7 @@ export const createTektonResultsUrl = ( )}`, ...(nextPageToken ? { ['page_token']: nextPageToken } : {}), filter: AND( - EQ('data_type', dataType.toString()), + IN('data_type', dataTypes), filter, selectorToFilter(options?.selector), options?.filter, @@ -215,7 +222,7 @@ export const createTektonResultsUrl = ( export const getFilteredRecord = async ( workspace: string, namespace: string, - dataType: DataType, + dataTypes: DataType[], filter?: string, options?: TektonResultsOptions, nextPageToken?: string, @@ -224,7 +231,7 @@ export const getFilteredRecord = async ( const url = createTektonResultsUrl( workspace, namespace, - dataType, + dataTypes, filter, options, nextPageToken, @@ -290,7 +297,7 @@ const getFilteredPipelineRuns = ( getFilteredRecord( workspace, namespace, - DataType.PipelineRun, + [DataType.PipelineRun, DataType.PipelineRun_v1beta1], filter, options, nextPageToken, @@ -308,7 +315,7 @@ const getFilteredTaskRuns = ( getFilteredRecord( workspace, namespace, - DataType.TaskRun, + [DataType.TaskRun, DataType.TaskRun_v1beta1], filter, options, nextPageToken, @@ -333,25 +340,18 @@ export const getTaskRuns = ( cacheKey?: string, ) => getFilteredTaskRuns(workspace, namespace, '', options, nextPageToken, cacheKey); -const getLog = (workspace: string, taskRunPath: string) => - commonFetchText(`${getTRUrlPrefix(workspace)}/${taskRunPath.replace('/records/', '/logs/')}`); +// const getLog = (workspace: string, taskRunPath: string) => +// commonFetchText(`${getTRUrlPrefix(workspace)}/${taskRunPath.replace('/records/', '/logs/')}`); export const getTaskRunLog = ( workspace: string, namespace: string, - taskRunName: string, + pid: string, + taskRunID: string, ): Promise => - getFilteredRecord( - workspace, - namespace, - DataType.Log, - AND(EQ(`data.spec.resource.kind`, 'TaskRun'), EQ(`data.spec.resource.name`, taskRunName)), - { limit: 1 }, - ).then((x) => - x?.[1]?.records.length > 0 - ? getLog(workspace, x?.[1]?.records[0].name).catch(() => throw404()) - : throw404(), - ); + commonFetchText( + `${getTRUrlPrefix(workspace)}/${namespace}/results/${pid}/logs/${taskRunID}`, + ).catch(() => throw404()); export const createTektonResultsQueryKeys = ( model: K8sModelCommon,