From 90636e2ca64eb1e620029ccbb254812c172a5f87 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 11 Nov 2026 10:23:31 +0000 Subject: [PATCH] fix: update Kubernetes secrets without deleting them --- src/server/services/secret-save.unit.spec.ts | 48 +++++++++++++++++++ src/server/services/secret.service.ts | 49 ++++++++++++++++---- 2 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 src/server/services/secret-save.unit.spec.ts diff --git a/src/server/services/secret-save.unit.spec.ts b/src/server/services/secret-save.unit.spec.ts new file mode 100644 index 00000000..94dd2839 --- /dev/null +++ b/src/server/services/secret-save.unit.spec.ts @@ -0,0 +1,48 @@ +import secretService from './secret.service'; + +const { core } = vi.hoisted(() => ({ + core: { + readNamespacedSecret: vi.fn(), + replaceNamespacedSecret: vi.fn(), + createNamespacedSecret: vi.fn(), + listNamespacedSecret: vi.fn(), + deleteNamespacedSecret: vi.fn(), + }, +})); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: { core } })); + +describe('saveSecret', () => { + const manifest = { metadata: { name: 'pull-secret' }, data: { token: 'new' } }; + + beforeEach(() => vi.resetAllMocks()); + + it('replaces an existing secret using its resource version without deleting it', async () => { + core.readNamespacedSecret.mockResolvedValue({ metadata: { name: 'pull-secret', resourceVersion: '1', labels: { owner: 'app' } } }); + await secretService.saveSecret('project', 'pull-secret', manifest); + expect(core.replaceNamespacedSecret).toHaveBeenCalledWith({ + namespace: 'project', name: 'pull-secret', + body: { ...manifest, metadata: { name: 'pull-secret', resourceVersion: '1', labels: { owner: 'app' } } }, + }); + expect(core.deleteNamespacedSecret).not.toHaveBeenCalled(); + }); + + it('retries with the latest resource version after a conflict', async () => { + core.readNamespacedSecret + .mockResolvedValueOnce({ metadata: { name: 'pull-secret', resourceVersion: '1' } }) + .mockResolvedValueOnce({ metadata: { name: 'pull-secret', resourceVersion: '2' } }); + core.replaceNamespacedSecret.mockRejectedValueOnce({ code: 409 }).mockResolvedValueOnce({}); + await secretService.saveSecret('project', 'pull-secret', manifest); + expect(core.replaceNamespacedSecret).toHaveBeenLastCalledWith(expect.objectContaining({ + body: expect.objectContaining({ metadata: expect.objectContaining({ resourceVersion: '2' }) }), + })); + }); + + it('retries when another caller creates the secret first', async () => { + core.readNamespacedSecret.mockRejectedValueOnce({ code: 404 }) + .mockResolvedValueOnce({ metadata: { name: 'pull-secret', resourceVersion: '1' } }); + core.createNamespacedSecret.mockRejectedValueOnce({ code: 409 }); + await secretService.saveSecret('project', 'pull-secret', manifest); + expect(core.replaceNamespacedSecret).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/server/services/secret.service.ts b/src/server/services/secret.service.ts index 15cc97f4..2351c5c1 100644 --- a/src/server/services/secret.service.ts +++ b/src/server/services/secret.service.ts @@ -92,17 +92,50 @@ class SecretService { } async updateSecret(namespace: string, secretName: string, secretManifest: V1Secret) { - console.log(`Updating secret ${secretName}...`); - await this.deleteSecretIfExists(namespace, secretName); - await this.createSecret(namespace, secretManifest); + await this.saveSecret(namespace, secretName, secretManifest); } async saveSecret(namespace: string, secretName: string, secretManifest: V1Secret) { - const existingSecret = await this.getExistingSecret(namespace, secretName); - if (existingSecret) { - await this.updateSecret(namespace, secretName, secretManifest); - } else { - await this.createSecret(namespace, secretManifest); + //Sometimes issues occured, when starting a agent sandbox beecause of race conditions. Yes its ugly, but for now it works. Investiage in future releases. + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let existingSecret: V1Secret; + try { + existingSecret = await k3s.core.readNamespacedSecret({ namespace, name: secretName }); + } catch (err) { + if ((err as ApiException).code !== 404) throw err; + try { + await this.createSecret(namespace, secretManifest); + return; + } catch (createError) { + if ((createError as ApiException).code !== 409 || attempt === maxAttempts - 1) { + throw createError; + } + continue; + } + } + + try { + console.log('replacing bitch') + await k3s.core.replaceNamespacedSecret({ + namespace, + name: secretName, + body: { + ...secretManifest, + metadata: { + ...existingSecret.metadata, + ...secretManifest.metadata, + resourceVersion: existingSecret.metadata?.resourceVersion, + }, + }, + }); + return; + } catch (err) { + const code = (err as ApiException).code; + if ((code !== 409 && code !== 404) || attempt === maxAttempts - 1) { + throw err; + } + } } }