Skip to content

Commit b6c1049

Browse files
authored
fix(x2a): job secrets to be owned by the job (#2710)
Currently the secret is not ownwed by the job, so it was not deleted when the Job was GC. This align secrets with the jobs. FIX FLPATH-3555 Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
1 parent 2fcdcaf commit b6c1049

4 files changed

Lines changed: 127 additions & 113 deletions

File tree

workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,21 @@ describe('JobResourceBuilder', () => {
405405
branch: 'main',
406406
},
407407
};
408+
const ownerReference = {
409+
apiVersion: 'batch/v1',
410+
kind: 'Job',
411+
name: 'job-x2a-init-abc123',
412+
uid: 'test-uid-123',
413+
blockOwnerDeletion: true,
414+
};
408415

409416
it('should include source repository credentials', () => {
410417
const secret = JobResourceBuilder.buildJobSecret(
411418
jobId,
412419
projectId,
413420
phase,
414421
gitCredentials,
422+
ownerReference,
415423
);
416424

417425
expect(secret.stringData).toMatchObject({
@@ -427,6 +435,7 @@ describe('JobResourceBuilder', () => {
427435
projectId,
428436
phase,
429437
gitCredentials,
438+
ownerReference,
430439
);
431440

432441
expect(secret.stringData).toMatchObject({
@@ -442,6 +451,7 @@ describe('JobResourceBuilder', () => {
442451
projectId,
443452
phase,
444453
gitCredentials,
454+
ownerReference,
445455
);
446456

447457
// Job secret should only have Git credentials
@@ -457,6 +467,7 @@ describe('JobResourceBuilder', () => {
457467
projectId,
458468
phase,
459469
gitCredentials,
470+
ownerReference,
460471
);
461472

462473
expect(secret.metadata?.labels).toMatchObject({
@@ -475,6 +486,7 @@ describe('JobResourceBuilder', () => {
475486
projectId,
476487
phase,
477488
gitCredentials,
489+
ownerReference,
478490
);
479491

480492
expect(secret.metadata?.name).toBe(`x2a-job-secret-${phase}-${jobId}`);
@@ -486,6 +498,7 @@ describe('JobResourceBuilder', () => {
486498
projectId,
487499
phase,
488500
gitCredentials,
501+
ownerReference,
489502
);
490503

491504
expect(secret.metadata?.annotations).toMatchObject({
@@ -495,12 +508,25 @@ describe('JobResourceBuilder', () => {
495508
});
496509
});
497510

511+
it('should include ownerReferences to the parent Job', () => {
512+
const secret = JobResourceBuilder.buildJobSecret(
513+
jobId,
514+
projectId,
515+
phase,
516+
gitCredentials,
517+
ownerReference,
518+
);
519+
520+
expect(secret.metadata?.ownerReferences).toEqual([ownerReference]);
521+
});
522+
498523
it('should be Opaque secret type', () => {
499524
const secret = JobResourceBuilder.buildJobSecret(
500525
jobId,
501526
projectId,
502527
phase,
503528
gitCredentials,
529+
ownerReference,
504530
);
505531

506532
expect(secret.type).toBe('Opaque');

workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import { resolvePackagePath } from '@backstage/backend-plugin-api';
18-
import { V1Job, V1Secret } from '@kubernetes/client-node';
18+
import { V1Job, V1OwnerReference, V1Secret } from '@kubernetes/client-node';
1919
import crypto from 'node:crypto';
2020
import fs from 'node:fs';
2121
import { X2AConfig } from '../../config';
@@ -137,13 +137,14 @@ export class JobResourceBuilder {
137137
/**
138138
* Builds a Kubernetes Secret for a specific job containing ephemeral Git credentials
139139
*
140-
* Note: ownerReferences are NOT set here because the job doesn't exist yet at secret
141-
* creation time. After job creation, KubeService.createJob() sets the ownerReference
142-
* on this secret so it is automatically garbage-collected when the Job is deleted.
140+
* The ownerReference links this secret to the Job so it is automatically
141+
* garbage-collected when the Job is deleted by the TTL controller.
143142
*
144143
* @param jobId - The job UUID
145144
* @param projectId - The project UUID
145+
* @param phase - The migration phase
146146
* @param gitCredentials - Git repository credentials from the user
147+
* @param ownerReference - Owner reference to the parent Job for garbage collection
147148
* @returns V1Secret resource ready to be created in Kubernetes
148149
*/
149150
static buildJobSecret(
@@ -154,6 +155,7 @@ export class JobResourceBuilder {
154155
sourceRepo: GitRepo;
155156
targetRepo: GitRepo;
156157
},
158+
ownerReference: V1OwnerReference,
157159
): V1Secret {
158160
const secretName = `x2a-job-secret-${phase}-${jobId}`;
159161

@@ -175,6 +177,7 @@ export class JobResourceBuilder {
175177
'x2a.redhat.com/description':
176178
'Ephemeral Git credentials for X2A job (auto-deleted with job)',
177179
},
180+
ownerReferences: [ownerReference],
178181
},
179182
type: 'Opaque',
180183
stringData: {

workspaces/x2a/plugins/x2a-backend/src/services/KubeService.test.ts

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { KubeService } from './KubeService';
2222
const mockCoreV1Api = {
2323
createNamespacedSecret: jest.fn(),
2424
readNamespacedSecret: jest.fn(),
25-
replaceNamespacedSecret: jest.fn(),
2625
deleteNamespacedSecret: jest.fn(),
2726
listNamespacedPod: jest.fn(),
2827
readNamespacedPodLog: jest.fn(),
@@ -252,19 +251,9 @@ describe('KubeService', () => {
252251
beforeEach(() => {
253252
// Mock createProjectSecret and createJobSecret to succeed
254253
mockCoreV1Api.createNamespacedSecret.mockResolvedValue({});
255-
// Mock readNamespacedSecret and replaceNamespacedSecret for ownerReference patching
256-
mockCoreV1Api.readNamespacedSecret.mockResolvedValue({
257-
apiVersion: 'v1',
258-
kind: 'Secret',
259-
metadata: {
260-
name: 'x2a-job-secret-init-job-123',
261-
namespace: 'test-namespace',
262-
},
263-
});
264-
mockCoreV1Api.replaceNamespacedSecret.mockResolvedValue({});
265254
});
266255

267-
it('should create both project and job secrets before creating job', async () => {
256+
it('should create project secret before job, and job secret after job with ownerReference', async () => {
268257
mockBatchV1Api.createNamespacedJob.mockResolvedValue({
269258
metadata: { name: 'job-x2a-init-abc123', uid: 'uid-123' },
270259
});
@@ -283,13 +272,22 @@ describe('KubeService', () => {
283272
}),
284273
);
285274

286-
// Should create job secret (Git credentials)
275+
// Should create job secret (Git credentials) with ownerReference to the job
287276
expect(mockCoreV1Api.createNamespacedSecret).toHaveBeenCalledWith(
288277
expect.objectContaining({
289278
namespace: 'test-namespace',
290279
body: expect.objectContaining({
291280
metadata: expect.objectContaining({
292281
name: 'x2a-job-secret-init-job-123',
282+
ownerReferences: [
283+
expect.objectContaining({
284+
apiVersion: 'batch/v1',
285+
kind: 'Job',
286+
name: expect.stringMatching(/^job-x2a-init-/),
287+
uid: 'uid-123',
288+
blockOwnerDeletion: true,
289+
}),
290+
],
293291
}),
294292
stringData: expect.objectContaining({
295293
SOURCE_REPO_URL: 'https://github.com/org/source',
@@ -342,51 +340,65 @@ describe('KubeService', () => {
342340
expect(result.k8sJobName).toBeDefined();
343341
});
344342

345-
it('should set ownerReference on job secret after job creation', async () => {
343+
it('should create job secret with ownerReference containing job uid', async () => {
346344
mockBatchV1Api.createNamespacedJob.mockResolvedValue({
347345
metadata: { name: 'job-x2a-init-abc123', uid: 'uid-456' },
348346
});
349347

350348
await kubeService.createJob(params);
351349

352-
// Should read the job secret
353-
expect(mockCoreV1Api.readNamespacedSecret).toHaveBeenCalledWith({
354-
name: 'x2a-job-secret-init-job-123',
355-
namespace: 'test-namespace',
356-
});
350+
// Job secret should be the second createNamespacedSecret call (after project secret)
351+
const calls = mockCoreV1Api.createNamespacedSecret.mock.calls;
352+
const jobSecretCall = calls.find(
353+
(c: any) => c[0].body.metadata?.name === 'x2a-job-secret-init-job-123',
354+
);
355+
expect(jobSecretCall).toBeDefined();
357356

358-
// Should replace the secret with ownerReference set
359-
expect(mockCoreV1Api.replaceNamespacedSecret).toHaveBeenCalled();
360-
const replaceCall =
361-
mockCoreV1Api.replaceNamespacedSecret.mock.calls[0][0];
362-
expect(replaceCall.name).toBe('x2a-job-secret-init-job-123');
363-
expect(replaceCall.namespace).toBe('test-namespace');
364-
const ownerRefs = replaceCall.body.metadata.ownerReferences;
357+
const ownerRefs = jobSecretCall![0].body.metadata.ownerReferences;
365358
expect(ownerRefs).toHaveLength(1);
366-
expect(ownerRefs[0]).toEqual(
359+
expect(ownerRefs[0]).toEqual({
360+
apiVersion: 'batch/v1',
361+
kind: 'Job',
362+
name: expect.stringMatching(/^job-x2a-init-/),
363+
uid: 'uid-456',
364+
blockOwnerDeletion: true,
365+
});
366+
});
367+
368+
it('should delete job and throw if job secret creation fails', async () => {
369+
mockBatchV1Api.createNamespacedJob.mockResolvedValue({
370+
metadata: { name: 'job-x2a-init-abc123', uid: 'uid-456' },
371+
});
372+
mockBatchV1Api.deleteNamespacedJob.mockResolvedValue({});
373+
// First call succeeds (project secret), second fails (job secret)
374+
mockCoreV1Api.createNamespacedSecret
375+
.mockResolvedValueOnce({})
376+
.mockRejectedValueOnce(new Error('Secret creation failed'));
377+
378+
await expect(kubeService.createJob(params)).rejects.toThrow(
379+
'Secret creation failed',
380+
);
381+
382+
// Should clean up the orphaned job
383+
expect(mockBatchV1Api.deleteNamespacedJob).toHaveBeenCalledWith(
367384
expect.objectContaining({
368-
apiVersion: 'batch/v1',
369-
kind: 'Job',
370-
uid: 'uid-456',
371-
blockOwnerDeletion: true,
385+
namespace: 'test-namespace',
372386
}),
373387
);
374-
expect(ownerRefs[0].name).toMatch(/^job-x2a-init-/);
375388
});
376389

377-
it('should succeed even if ownerReference patching fails', async () => {
390+
it('should delete job and throw if created job has no UID', async () => {
378391
mockBatchV1Api.createNamespacedJob.mockResolvedValue({
379-
metadata: { name: 'job-x2a-init-abc123', uid: 'uid-456' },
392+
metadata: { name: 'job-x2a-init-abc123' },
380393
});
381-
mockCoreV1Api.readNamespacedSecret.mockRejectedValue(
382-
new Error('Secret read failed'),
383-
);
394+
mockBatchV1Api.deleteNamespacedJob.mockResolvedValue({});
384395

385-
const result = await kubeService.createJob(params);
396+
await expect(kubeService.createJob(params)).rejects.toThrow(
397+
/created without UID/,
398+
);
386399

387-
// Job creation should still succeed
388-
expect(result.k8sJobName).toBeDefined();
389-
expect(mockBatchV1Api.createNamespacedJob).toHaveBeenCalled();
400+
// Should clean up the orphaned job
401+
expect(mockBatchV1Api.deleteNamespacedJob).toHaveBeenCalled();
390402
});
391403
});
392404

0 commit comments

Comments
 (0)