Skip to content

Commit 79f08ac

Browse files
committed
fix: set correct Content-Type for Kubernetes PATCH requests
Assisted-by: Claude Opus 4.5 Signed-off-by: Oleksii Orel <oorel@redhat.com>
1 parent e089ade commit 79f08ac

File tree

8 files changed

+143
-65
lines changed

8 files changed

+143
-65
lines changed

packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceApi.spec.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,17 @@ describe('DevWorkspace API Service', () => {
142142
const res = await devWorkspaceService.patch(namespace, name, patches);
143143
expect(res.devWorkspace).toStrictEqual(getDevWorkspace());
144144
expect(res.headers).toStrictEqual({});
145-
expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith({
146-
group: devworkspaceGroup,
147-
version: devworkspaceLatestVersion,
148-
namespace,
149-
plural: devworkspacePlural,
150-
name,
151-
body: patches,
152-
});
145+
expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith(
146+
{
147+
group: devworkspaceGroup,
148+
version: devworkspaceLatestVersion,
149+
namespace,
150+
plural: devworkspacePlural,
151+
name,
152+
body: patches,
153+
},
154+
expect.anything(),
155+
);
153156
});
154157

155158
test('deleting', async () => {

packages/dashboard-backend/src/devworkspaceClient/services/__tests__/devWorkspaceTemplateApi.spec.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,17 @@ describe('DevWorkspaceTemplate API Service', () => {
139139

140140
const res = await templateService.patch(namespace, name, patches);
141141
expect(res).toEqual(getDevWorkspaceTemplate());
142-
expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith({
143-
group: devworkspacetemplateGroup,
144-
version: devworkspacetemplateLatestVersion,
145-
namespace,
146-
plural: devworkspacetemplatePlural,
147-
name,
148-
body: patches,
149-
});
142+
expect(spyPatchNamespacedCustomObject).toHaveBeenCalledWith(
143+
{
144+
group: devworkspacetemplateGroup,
145+
version: devworkspacetemplateLatestVersion,
146+
namespace,
147+
plural: devworkspacetemplatePlural,
148+
name,
149+
body: patches,
150+
},
151+
expect.anything(),
152+
);
150153
});
151154

152155
test('deleting', async () => {

packages/dashboard-backend/src/devworkspaceClient/services/__tests__/workspacePreferencesApi.spec.ts

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,16 @@ describe('Workspace Preferences API Service', () => {
9595

9696
expect(spyReadNamespacedConfigMap).toHaveBeenCalled();
9797
expect(spyPatchNamespacedConfigMap).toHaveBeenCalled();
98-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
99-
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
100-
namespace: 'user-che',
101-
body: {
102-
data: { [SKIP_AUTHORIZATION_KEY]: '[github, bitbucket]' },
98+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
99+
{
100+
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
101+
namespace: 'user-che',
102+
body: {
103+
data: { [SKIP_AUTHORIZATION_KEY]: '[github, bitbucket]' },
104+
},
103105
},
104-
});
106+
expect.anything(),
107+
);
105108
});
106109

107110
test('add a very first trusted source URL to trusted-source workspace preferences', async () => {
@@ -113,16 +116,19 @@ describe('Workspace Preferences API Service', () => {
113116

114117
expect(spyReadNamespacedConfigMap).toHaveBeenCalled();
115118
expect(spyPatchNamespacedConfigMap).toHaveBeenCalled();
116-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
117-
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
118-
namespace,
119-
body: {
120-
data: {
121-
[SKIP_AUTHORIZATION_KEY]: '[]',
122-
[TRUSTED_SOURCES_KEY]: '["source-url"]',
119+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
120+
{
121+
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
122+
namespace,
123+
body: {
124+
data: {
125+
[SKIP_AUTHORIZATION_KEY]: '[]',
126+
[TRUSTED_SOURCES_KEY]: '["source-url"]',
127+
},
123128
},
124129
},
125-
});
130+
expect.anything(),
131+
);
126132
});
127133

128134
test('add a new trusted source URL to trusted-source workspace preferences', async () => {
@@ -136,16 +142,19 @@ describe('Workspace Preferences API Service', () => {
136142

137143
expect(spyReadNamespacedConfigMap).toHaveBeenCalled();
138144
expect(spyPatchNamespacedConfigMap).toHaveBeenCalled();
139-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
140-
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
141-
namespace,
142-
body: {
143-
data: {
144-
[SKIP_AUTHORIZATION_KEY]: '[]',
145-
[TRUSTED_SOURCES_KEY]: '["source1","source2","source3"]',
145+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
146+
{
147+
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
148+
namespace,
149+
body: {
150+
data: {
151+
[SKIP_AUTHORIZATION_KEY]: '[]',
152+
[TRUSTED_SOURCES_KEY]: '["source1","source2","source3"]',
153+
},
146154
},
147155
},
148-
});
156+
expect.anything(),
157+
);
149158
});
150159

151160
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', () => {
159168

160169
expect(spyReadNamespacedConfigMap).toHaveBeenCalled();
161170
expect(spyPatchNamespacedConfigMap).toHaveBeenCalled();
162-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
163-
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
164-
namespace,
165-
body: {
166-
data: {
167-
[SKIP_AUTHORIZATION_KEY]: '[]',
168-
[TRUSTED_SOURCES_KEY]: '"*"',
171+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
172+
{
173+
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
174+
namespace,
175+
body: {
176+
data: {
177+
[SKIP_AUTHORIZATION_KEY]: '[]',
178+
[TRUSTED_SOURCES_KEY]: '"*"',
179+
},
169180
},
170181
},
171-
});
182+
expect.anything(),
183+
);
172184
});
173185

174186
test('delete all trusted sources from trusted-source workspace preferences', async () => {
@@ -182,14 +194,17 @@ describe('Workspace Preferences API Service', () => {
182194

183195
expect(spyReadNamespacedConfigMap).toHaveBeenCalled();
184196
expect(spyPatchNamespacedConfigMap).toHaveBeenCalled();
185-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
186-
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
187-
namespace,
188-
body: {
189-
data: {
190-
[SKIP_AUTHORIZATION_KEY]: '[]',
197+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
198+
{
199+
name: DEV_WORKSPACE_PREFERENCES_CONFIGMAP,
200+
namespace,
201+
body: {
202+
data: {
203+
[SKIP_AUTHORIZATION_KEY]: '[]',
204+
},
191205
},
192206
},
193-
});
207+
expect.anything(),
208+
);
194209
});
195210
});

packages/dashboard-backend/src/devworkspaceClient/services/gitConfigApi/__tests__/index.spec.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,21 @@ email=""
168168

169169
expect(spyReadNamespacedConfigMap).toHaveBeenCalledTimes(1);
170170
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledTimes(1);
171-
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith({
172-
name: 'workspace-userdata-gitconfig-configmap',
173-
namespace: 'user-che',
174-
body: {
175-
data: {
176-
gitconfig: `[user]
171+
expect(spyPatchNamespacedConfigMap).toHaveBeenCalledWith(
172+
{
173+
name: 'workspace-userdata-gitconfig-configmap',
174+
namespace: 'user-che',
175+
body: {
176+
data: {
177+
gitconfig: `[user]
177178
email="user-2@che"
178179
name="User Two"
179180
`,
181+
},
180182
},
181183
},
182-
});
184+
expect.anything(),
185+
);
183186
});
184187

185188
it('should throw when can`t read the ConfigMap', async () => {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
import { PatchStrategy } from '@kubernetes/client-node';
14+
import {
15+
ConfigurationOptions,
16+
RequestContext,
17+
ResponseContext,
18+
} from '@kubernetes/client-node/dist/gen';
19+
import { wrapOptions } from '@kubernetes/client-node/dist/gen/configuration';
20+
21+
/**
22+
* Creates a middleware that explicitly sets the Content-Type header for patch requests.
23+
* This ensures consistent behavior regardless of @kubernetes/client-node defaults.
24+
*/
25+
function createPatchMiddleware(contentType: string) {
26+
return {
27+
pre: async (context: RequestContext): Promise<RequestContext> => {
28+
context.setHeaderParam('Content-Type', contentType);
29+
return context;
30+
},
31+
post: async (context: ResponseContext): Promise<ResponseContext> => {
32+
return context;
33+
},
34+
};
35+
}
36+
37+
/**
38+
* Configuration options for patch operations on built-in Kubernetes resources (ConfigMaps, etc.).
39+
* Uses strategic-merge-patch for merge patch format: { data: { key: value } }
40+
*/
41+
export const STRATEGIC_MERGE_PATCH_OPTIONS: ConfigurationOptions = wrapOptions({
42+
middleware: [createPatchMiddleware(PatchStrategy.StrategicMergePatch)],
43+
})!;
44+
45+
/**
46+
* Configuration options for patch operations on Custom Resources (DevWorkspaces, etc.).
47+
* Uses json-patch for JSON Patch array format: [{ op: 'replace', path: '...', value: '...' }]
48+
*/
49+
export const JSON_PATCH_OPTIONS: ConfigurationOptions = wrapOptions({
50+
middleware: [createPatchMiddleware(PatchStrategy.JsonPatch)],
51+
})!;

packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import * as k8s from '@kubernetes/client-node';
1414

15+
import { STRATEGIC_MERGE_PATCH_OPTIONS } from '@/devworkspaceClient/services/helpers/patchOptions';
1516
import { retryableExec } from '@/devworkspaceClient/services/helpers/retryableExec';
1617

1718
export type CoreV1API = Pick<
@@ -46,8 +47,8 @@ export function prepareCoreV1API(kc: k8s.KubeConfig): CoreV1API {
4647
retryableExec(() => coreV1API.listNamespacedPod(...args)),
4748
listNamespacedSecret: (...args: Parameters<typeof coreV1API.listNamespacedSecret>) =>
4849
retryableExec(() => coreV1API.listNamespacedSecret(...args)),
49-
patchNamespacedConfigMap: (...args: Parameters<typeof coreV1API.patchNamespacedConfigMap>) =>
50-
retryableExec(() => coreV1API.patchNamespacedConfigMap(...args)),
50+
patchNamespacedConfigMap: (param: Parameters<typeof coreV1API.patchNamespacedConfigMap>[0]) =>
51+
retryableExec(() => coreV1API.patchNamespacedConfigMap(param, STRATEGIC_MERGE_PATCH_OPTIONS)),
5152
readNamespacedConfigMap: (...args: Parameters<typeof coreV1API.readNamespacedConfigMap>) =>
5253
retryableExec(() => coreV1API.readNamespacedConfigMap(...args)),
5354
readNamespacedPod: (...args: Parameters<typeof coreV1API.readNamespacedPod>) =>

packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCustomObjectAPI.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import * as k8s from '@kubernetes/client-node';
1414

15+
import { JSON_PATCH_OPTIONS } from '@/devworkspaceClient/services/helpers/patchOptions';
1516
import { retryableExec } from '@/devworkspaceClient/services/helpers/retryableExec';
1617

1718
export type CustomObjectAPI = Pick<
@@ -50,7 +51,8 @@ export function prepareCustomObjectAPI(kc: k8s.KubeConfig): CustomObjectAPI {
5051
...args: Parameters<typeof customObjectsApi.deleteNamespacedCustomObject>
5152
) => retryableExec(() => customObjectsApi.deleteNamespacedCustomObject(...args)),
5253
patchNamespacedCustomObject: (
53-
...args: Parameters<typeof customObjectsApi.patchNamespacedCustomObject>
54-
) => retryableExec(() => customObjectsApi.patchNamespacedCustomObject(...args)),
54+
param: Parameters<typeof customObjectsApi.patchNamespacedCustomObject>[0],
55+
) =>
56+
retryableExec(() => customObjectsApi.patchNamespacedCustomObject(param, JSON_PATCH_OPTIONS)),
5557
};
5658
}

packages/dashboard-backend/src/devworkspaceClient/services/workspacePreferencesApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class WorkspacePreferencesApiService implements IWorkspacePreferencesApi
8686
});
8787
} catch (error) {
8888
const message = `Unable to update workspace preferences in the namespace "${namespace}"`;
89-
throw createError(undefined, ERROR_LABEL, message);
89+
throw createError(error, ERROR_LABEL, message);
9090
}
9191
}
9292

0 commit comments

Comments
 (0)