Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(pipeline-run): open websocket in case of abnormally closed, implement polling, releases watch #43

Merged
merged 4 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
namespace,
workspace,
applicationName,
true,

Check warning on line 65 in src/components/Components/ComponentsListView/ComponentListView.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Components/ComponentsListView/ComponentListView.tsx#L65

Added line #L65 was not covered by tests
);
const [canCreateComponent] = useAccessReviewForModel(ComponentModel, 'create');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import { useTaskRuns } from '../../hooks/useTaskRuns';
import { commonFetchJSON, getK8sResourceURL } from '../../k8s';
import { PodModel } from '../../models/pod';
import { getPipelineRunFromTaskRunOwnerRef } from '../../utils/common-utils';
import { getTaskRunLog } from '../../utils/tekton-results';
import { useWorkspaceInfo } from '../Workspace/useWorkspaceInfo';
import {
Expand Down Expand Up @@ -65,10 +66,12 @@ export const useEnterpriseContractResultFromLogs = (
if (fetchTknLogs) {
const fetch = async () => {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
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';
Expand All @@ -27,6 +25,8 @@
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';

Check warning on line 29 in src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/PipelineRun/PipelineRunDetailsView/tabs/PipelineRunDetailsTab.tsx#L28-L29

Added lines #L28 - L29 were not covered by tests
import { getCommitSha, getCommitShortName } from '../../../../utils/commits-utils';
import {
calculateDuration,
Expand Down Expand Up @@ -82,8 +82,8 @@
);
}
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'
Expand Down
32 changes: 27 additions & 5 deletions src/hooks/__tests__/useTektonResults.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -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]);
Expand All @@ -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]);
});
});
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useApplicationReleases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const useApplicationReleases = (
namespace,
workspace,
isList: true,
watch: true,
},
ReleaseModel,
);
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const useComponents = (
namespace: string,
workspace: string,
applicationName: string,
watch?: boolean,
): [ComponentKind[], boolean, unknown] => {
const {
data: components,
Expand All @@ -49,6 +50,7 @@ export const useComponents = (
workspace,
namespace,
isList: true,
watch,
},
ComponentModel,
);
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useReleases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace,
workspace,
isList: true,
watch: true,

Check warning on line 15 in src/hooks/useReleases.ts

View check run for this annotation

Codecov / codecov/patch

src/hooks/useReleases.ts#L15

Added line #L15 was not covered by tests
},
ReleaseModel,
);
Expand All @@ -29,6 +30,7 @@
namespace,
workspace,
name,
watch: true,
},
ReleaseModel,
);
Expand Down
11 changes: 7 additions & 4 deletions src/hooks/useTektonResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
}
Expand All @@ -84,6 +87,6 @@ export const useTRTaskRunLog = (
return () => {
disposed = true;
};
}, [workspace, namespace, taskRunName]);
}, [workspace, namespace, taskRunUid, pipelineRunUid]);
return result;
};
178 changes: 178 additions & 0 deletions src/k8s/hooks/__tests__/useK8sQueryWatch.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading