diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceApi.spec.ts index b8e575682f..6ab7e57ffd 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceApi.spec.ts @@ -142,14 +142,17 @@ describe('DevWorkspace API Service', () => { const res = await devWorkspaceService.patch(namespace, name, patches); expect(res.devWorkspace).toStrictEqual(getDevWorkspace()); expect(res.headers).toStrictEqual({}); - expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith({ - group: devworkspaceGroup, - version: devworkspaceLatestVersion, - namespace, - plural: devworkspacePlural, - name, - body: patches, - }); + expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith( + { + group: devworkspaceGroup, + version: devworkspaceLatestVersion, + namespace, + plural: devworkspacePlural, + name, + body: patches, + }, + expect.anything(), + ); }); test('deleting', async () => { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceTemplateApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceTemplateApi.spec.ts index 3ee8a7e8b5..971a18cfe8 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceTemplateApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceTemplateApi.spec.ts @@ -139,14 +139,17 @@ describe('DevWorkspaceTemplate API Service', () => { const res = await templateService.patch(namespace, name, patches); expect(res).toEqual(getDevWorkspaceTemplate()); - expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith({ - group: devworkspacetemplateGroup, - version: devworkspacetemplateLatestVersion, - namespace, - plural: devworkspacetemplatePlural, - name, - body: patches, - }); + expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith( + { + group: devworkspacetemplateGroup, + version: devworkspacetemplateLatestVersion, + namespace, + plural: devworkspacetemplatePlural, + name, + body: patches, + }, + expect.anything(), + ); }); test('deleting', async () => { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/workspacePreferencesApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/workspacePreferencesApi.spec.ts index 2c004b08a2..ef87b3ade1 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/workspacePreferencesApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/workspacePreferencesApi.spec.ts @@ -95,13 +95,16 @@ describe('Workspace Preferences API Service', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalled(); expect(spyPatchNamespacedConfigMap).toHaveBeenCalled(); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, - namespace: 'user-che', - body: { - data: { [SKIP_AUTHORIZATION_KEY]: '[github, bitbucket]' }, + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, + namespace: 'user-che', + body: { + data: { [SKIP_AUTHORIZATION_KEY]: '[github, bitbucket]' }, + }, }, - }); + expect.anything(), + ); }); test('add a very first trusted source URL to trusted-source workspace preferences', async () => { @@ -113,16 +116,19 @@ describe('Workspace Preferences API Service', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalled(); expect(spyPatchNamespacedConfigMap).toHaveBeenCalled(); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, - namespace, - body: { - data: { - [SKIP_AUTHORIZATION_KEY]: '[]', - [TRUSTED_SOURCES_KEY]: '["source-url"]', + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, + namespace, + body: { + data: { + [SKIP_AUTHORIZATION_KEY]: '[]', + [TRUSTED_SOURCES_KEY]: '["source-url"]', + }, }, }, - }); + expect.anything(), + ); }); test('add a new trusted source URL to trusted-source workspace preferences', async () => { @@ -136,16 +142,19 @@ describe('Workspace Preferences API Service', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalled(); expect(spyPatchNamespacedConfigMap).toHaveBeenCalled(); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, - namespace, - body: { - data: { - [SKIP_AUTHORIZATION_KEY]: '[]', - [TRUSTED_SOURCES_KEY]: '["source1","source2","source3"]', + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, + namespace, + body: { + data: { + [SKIP_AUTHORIZATION_KEY]: '[]', + [TRUSTED_SOURCES_KEY]: '["source1","source2","source3"]', + }, }, }, - }); + expect.anything(), + ); }); test('add trust all to trusted-source workspace preferences when there is some trusted URLs', async () => { @@ -159,16 +168,19 @@ describe('Workspace Preferences API Service', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalled(); expect(spyPatchNamespacedConfigMap).toHaveBeenCalled(); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, - namespace, - body: { - data: { - [SKIP_AUTHORIZATION_KEY]: '[]', - [TRUSTED_SOURCES_KEY]: '"*"', + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, + namespace, + body: { + data: { + [SKIP_AUTHORIZATION_KEY]: '[]', + [TRUSTED_SOURCES_KEY]: '"*"', + }, }, }, - }); + expect.anything(), + ); }); test('delete all trusted sources from trusted-source workspace preferences', async () => { @@ -182,14 +194,17 @@ describe('Workspace Preferences API Service', () => { expect(spyReadNamespacedConfigMap).toHaveBeenCalled(); expect(spyPatchNamespacedConfigMap).toHaveBeenCalled(); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, - namespace, - body: { - data: { - [SKIP_AUTHORIZATION_KEY]: '[]', + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP, + namespace, + body: { + data: { + [SKIP_AUTHORIZATION_KEY]: '[]', + }, }, }, - }); + expect.anything(), + ); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts index 8fc4db2195..0e7a2bc6db 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts @@ -168,18 +168,21 @@ email="" expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1); expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(1); - expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({ - name: 'workspace-userdata-gitconfig-configmap', - namespace: 'user-che', - body: { - data: { - gitconfig: `[user] + expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith( + { + name: 'workspace-userdata-gitconfig-configmap', + namespace: 'user-che', + body: { + data: { + gitconfig: `[user] email="user-2@che" name="User Two" `, + }, }, }, - }); + expect.anything(), + ); }); it('should throw when can`t read the ConfigMap', async () => { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/patchOptions.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/patchOptions.ts new file mode 100644 index 0000000000..26c28c1b2d --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/patchOptions.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { PatchStrategy } from '@kubernetes/client-node'; +import { + ConfigurationOptions, + RequestContext, + ResponseContext, +} from '@kubernetes/client-node/dist/gen'; +import { wrapOptions } from '@kubernetes/client-node/dist/gen/configuration'; + +/** + * Creates a middleware that explicitly sets the Content-Type header for patch requests. + * This ensures consistent behavior regardless of @kubernetes/client-node defaults. + */ +function createPatchMiddleware(contentType: string) { + return { + pre: async (context: RequestContext): Promise => { + context.setHeaderParam('Content-Type', contentType); + return context; + }, + post: async (context: ResponseContext): Promise => { + return context; + }, + }; +} + +/** + * Configuration options for patch operations on built-in Kubernetes resources (ConfigMaps, etc.). + * Uses strategic-merge-patch for merge patch format: { data: { key: value } } + */ +export const STRATEGIC_MERGE_PATCH_OPTIONS: ConfigurationOptions = wrapOptions({ + middleware: [createPatchMiddleware(PatchStrategy.StrategicMergePatch)], +})!; + +/** + * Configuration options for patch operations on Custom Resources (DevWorkspaces, etc.). + * Uses json-patch for JSON Patch array format: [{ op: 'replace', path: '...', value: '...' }] + */ +export const JSON_PATCH_OPTIONS: ConfigurationOptions = wrapOptions({ + middleware: [createPatchMiddleware(PatchStrategy.JsonPatch)], +})!; diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts index 438bb0bc55..5cb11d494b 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts @@ -12,6 +12,7 @@ import * as k8s from '@kubernetes/client-node'; +import { STRATEGIC_MERGE_PATCH_OPTIONS } from '@/devworkspaceClient/services/helpers/patchOptions'; import { retryableExec } from '@/devworkspaceClient/services/helpers/retryableExec'; export type CoreV1API = Pick< @@ -46,8 +47,8 @@ export function prepareCoreV1API(kc: k8s.KubeConfig): CoreV1API { retryableExec(() => coreV1API.listNamespacedPod(...args)), listNamespacedSecret: (...args: Parameters) => retryableExec(() => coreV1API.listNamespacedSecret(...args)), - patchNamespacedConfigMap: (...args: Parameters) => - retryableExec(() => coreV1API.patchNamespacedConfigMap(...args)), + patchNamespacedConfigMap: (param: Parameters[0]) => + retryableExec(() => coreV1API.patchNamespacedConfigMap(param, STRATEGIC_MERGE_PATCH_OPTIONS)), readNamespacedConfigMap: (...args: Parameters) => retryableExec(() => coreV1API.readNamespacedConfigMap(...args)), readNamespacedPod: (...args: Parameters) => diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCustomObjectAPI.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCustomObjectAPI.ts index 8879175dcf..e878f61b25 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCustomObjectAPI.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCustomObjectAPI.ts @@ -12,6 +12,7 @@ import * as k8s from '@kubernetes/client-node'; +import { JSON_PATCH_OPTIONS } from '@/devworkspaceClient/services/helpers/patchOptions'; import { retryableExec } from '@/devworkspaceClient/services/helpers/retryableExec'; export type CustomObjectAPI = Pick< @@ -50,7 +51,8 @@ export function prepareCustomObjectAPI(kc: k8s.KubeConfig): CustomObjectAPI { ...args: Parameters ) => retryableExec(() => customObjectsApi.deleteNamespacedCustomObject(...args)), patchNamespacedCustomObject: ( - ...args: Parameters - ) => retryableExec(() => customObjectsApi.patchNamespacedCustomObject(...args)), + param: Parameters[0], + ) => + retryableExec(() => customObjectsApi.patchNamespacedCustomObject(param, JSON_PATCH_OPTIONS)), }; } diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/workspacePreferencesApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/workspacePreferencesApi.ts index 013b69dae1..659f6e8879 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/workspacePreferencesApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/workspacePreferencesApi.ts @@ -86,7 +86,7 @@ export class WorkspacePreferencesApiService implements IWorkspacePreferencesApi }); } catch (error) { const message = `Unable to update workspace preferences in the namespace "${namespace}"`; - throw createError(undefined, ERROR_LABEL, message); + throw createError(error, ERROR_LABEL, message); } }