• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

opendefensecloud / solution-arsenal / 23583390100

26 Mar 2026 07:51AM UTC coverage: 71.717% (-0.09%) from 71.809%
23583390100

push

github

web-flow
fixup(#160): fix cleanup after ttl and add tests (#331)

* tests: add tests for FailedJobTTL propagation

Add unit tests to verify FailedJobTTL is properly propagated through
the resource hierarchy:
- Release → RenderTask: verify FailedJobTTL flows from Release spec
  to RenderTask spec
- RenderTask → Job: verify TTLSecondsAfterFinished is set on the Job
  with the specified value or default (3600)

* fix: clean up secrets on failed job after FailedJobTTL expires

Previously, secrets (ConfigSecret, AuthSecret) were only cleaned up on job
success or when the parent Release was deleted. On failure, secrets
persisted indefinitely while only the Job was cleaned up by the Kubernetes
TTL controller.

Now the controller deletes secrets after FailedJobTTL duration when a job
fails, matching the behavior for job cleanup by the Kubernetes TTL
controller.

Changes:
- Add TTL-based secret cleanup logic for failed jobs (rendertask_controller.go)
- Update isJobComplete to handle failed jobs without CompletionTime
- Add requeue logic to wait for TTL expiration before cleanup
- Add test for secret cleanup after FailedJobTTL on failure
- Update FailedJobTTL docstring to reflect actual behavior

* refactor: un-spaghetti ttl cleanup

Using helper functions to make the flow readable again.

42 of 52 new or added lines in 1 file covered. (80.77%)

16 existing lines in 3 files now uncovered.

2201 of 3069 relevant lines covered (71.72%)

20.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.95
/pkg/controller/rendertask_controller.go
1
// Copyright 2026 BWI GmbH and Solution Arsenal contributors
2
// SPDX-License-Identifier: Apache-2.0
3

4
package controller
5

6
import (
7
        "context"
8
        "encoding/json"
9
        "fmt"
10
        "slices"
11
        "strings"
12
        "time"
13

14
        batchv1 "k8s.io/api/batch/v1"
15
        corev1 "k8s.io/api/core/v1"
16
        apierrors "k8s.io/apimachinery/pkg/api/errors"
17
        apimeta "k8s.io/apimachinery/pkg/api/meta"
18
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19
        "k8s.io/apimachinery/pkg/runtime"
20
        "k8s.io/client-go/tools/events"
21
        ctrl "sigs.k8s.io/controller-runtime"
22
        "sigs.k8s.io/controller-runtime/pkg/client"
23
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
24

25
        solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1"
26
)
27

28
const (
29
        renderTaskFinalizer = "solar.opendefense.cloud/rendertask-finalizer"
30

31
        annotationJobName    = "solar.opendefense.cloud/job-name"
32
        annotationSecretName = "solar.opendefense.cloud/secret-name"
33

34
        // Condition types
35
        ConditionTypeJobScheduled = "JobScheduled"
36
        ConditionTypeJobSucceeded = "JobSucceeded"
37
        ConditionTypeJobFailed    = "JobFailed"
38

39
        ConditionTypeTaskCompleted = "TaskCompleted"
40
        ConditionTypeTaskFailed    = "TaskFailed"
41
)
42

43
// RenderTaskReconciler reconciles a RenderTask object
44
type RenderTaskReconciler struct {
45
        client.Client
46
        Scheme              *runtime.Scheme
47
        Recorder            events.EventRecorder
48
        RendererImage       string
49
        RendererCommand     string
50
        RendererArgs        []string
51
        PushSecretRef       *corev1.SecretReference
52
        BaseURL             string
53
        RendererCAConfigMap string
54
        // WatchNamespace restricts reconciliation to this namespace.
55
        // Should be empty in production (watches all namespaces).
56
        // Intended for use in integration tests only.
57
        // See: https://book.kubebuilder.io/reference/envtest#testing-considerations
58
        WatchNamespace string
59
}
60

61
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=rendertasks,verbs=get;list;watch;create;update;patch;delete
62
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=rendertasks/status,verbs=get;update;patch
63
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=rendertasks/finalizers,verbs=update
64
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
65
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
66
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
67
//+kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
68

69
// Reconcile moves the current state of the cluster closer to the desired state
70
func (r *RenderTaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
324✔
71
        log := ctrl.LoggerFrom(ctx)
324✔
72
        ctrlResult := ctrl.Result{}
324✔
73

324✔
74
        log.V(1).Info("RenderTask is being reconciled", "req", req)
324✔
75

324✔
76
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
363✔
77
                return ctrlResult, nil
39✔
78
        }
39✔
79

80
        // Fetch the RenderTask instance
81
        res := &solarv1alpha1.RenderTask{}
285✔
82
        if err := r.Get(ctx, req.NamespacedName, res); err != nil {
294✔
83
                if apierrors.IsNotFound(err) {
18✔
84
                        // Object not found, return. Created objects are automatically garbage collected.
9✔
85
                        return ctrlResult, nil
9✔
86
                }
9✔
87

88
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
89
        }
90

91
        // Handle deletion: cleanup job and secret, then remove finalizer
92
        if !res.DeletionTimestamp.IsZero() {
287✔
93
                log.V(1).Info("RenderTask is being deleted")
11✔
94
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "Deleting", "Delete", "RenderTask is being deleted, cleaning up secret and job")
11✔
95

11✔
96
                // Cleanup render resources, if exists
11✔
97
                if err := r.deleteRenderJob(ctx, res); err != nil && !apierrors.IsNotFound(err) {
11✔
98
                        return ctrlResult, errLogAndWrap(log, err, "failed to clean up render job")
×
99
                }
×
100

101
                if err := r.deleteConfigSecret(ctx, res); err != nil && !apierrors.IsNotFound(err) {
11✔
102
                        return ctrlResult, errLogAndWrap(log, err, "failed to clean up render secret")
×
103
                }
×
104

105
                if err := r.deleteAuthSecret(ctx, res); err != nil && !apierrors.IsNotFound(err) {
11✔
106
                        return ctrlResult, errLogAndWrap(log, err, "failed to clean up auth secret")
×
107
                }
×
108

109
                // Remove finalizer
110
                if slices.Contains(res.Finalizers, renderTaskFinalizer) {
22✔
111
                        log.V(1).Info("Removing finalizer from resource")
11✔
112
                        res.Finalizers = slices.DeleteFunc(res.Finalizers, func(f string) bool {
22✔
113
                                return f == renderTaskFinalizer
11✔
114
                        })
11✔
115
                        if err := r.Update(ctx, res); err != nil {
11✔
UNCOV
116
                                return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer")
×
UNCOV
117
                        }
×
118
                }
119

120
                return ctrlResult, nil
11✔
121
        }
122

123
        // Add finalizer if not present and not deleting
124
        if res.DeletionTimestamp.IsZero() {
530✔
125
                if !slices.Contains(res.Finalizers, renderTaskFinalizer) {
312✔
126
                        log.V(1).Info("Adding finalizer to resource")
47✔
127
                        res.Finalizers = append(res.Finalizers, renderTaskFinalizer)
47✔
128
                        if err := r.Update(ctx, res); err != nil {
47✔
129
                                return ctrlResult, errLogAndWrap(log, err, "failed to add finalizer")
×
130
                        }
×
131
                        // Return without requeue; the Update event will trigger reconciliation again
132
                        return ctrlResult, nil
47✔
133
                }
134
        }
135

136
        // Check if renderjob has already completed successfully
137
        sc := apimeta.FindStatusCondition(res.Status.Conditions, ConditionTypeJobSucceeded)
218✔
138
        if sc != nil && sc.ObservedGeneration >= res.Generation && sc.Status == metav1.ConditionTrue {
224✔
139
                log.V(1).Info("RenderTask has already completed successfully, no further action needed")
6✔
140
                return ctrlResult, nil
6✔
141
        }
6✔
142

143
        // Reconcile Config Secret
144
        configSecret := &corev1.Secret{}
212✔
145
        err := r.Get(ctx, r.configSecretKey(res), configSecret)
212✔
146
        if err != nil && apierrors.IsNotFound(err) {
356✔
147
                createdSecret, err := r.createConfigSecret(ctx, res)
144✔
148
                if err != nil {
144✔
149
                        r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreateSecretFailed", "CreateConfigSecret", fmt.Sprintf("Failed to create config secret: %s", err))
×
150
                        return ctrlResult, errLogAndWrap(log, err, "failed to create secret")
×
151
                }
×
152
                configSecret = createdSecret
144✔
153
        } else if err != nil {
68✔
154
                return ctrlResult, errLogAndWrap(log, err, "could not get secret")
×
155
        }
×
156

157
        // Reconcile Auth Secret
158
        authSecret := &corev1.Secret{}
212✔
159
        err = r.Get(ctx, r.authSecretKey(res), authSecret)
212✔
160
        if err != nil && apierrors.IsNotFound(err) && r.PushSecretRef != nil {
356✔
161
                createdSecret, err := r.copyAuthSecret(ctx, res)
144✔
162
                if err != nil {
144✔
163
                        r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreateSecretFailed", "CreateAuthSecret", fmt.Sprintf("Failed to create auth secret: %s", err))
×
164
                        return ctrlResult, errLogAndWrap(log, err, "failed to copy auth secret to namespace")
×
165
                }
×
166
                authSecret = createdSecret
144✔
167
        } else if client.IgnoreNotFound(err) != nil {
68✔
168
                return ctrlResult, errLogAndWrap(log, err, "could not get auth secret")
×
169
        }
×
170

171
        // Reconcile Job
172
        job := &batchv1.Job{}
212✔
173
        err = r.Get(ctx, r.renderJobKey(res), job)
212✔
174
        if err != nil && apierrors.IsNotFound(err) {
259✔
175
                err := r.createRenderJob(ctx, res, configSecret, authSecret)
47✔
176
                if err != nil {
47✔
UNCOV
177
                        r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreateJobFailed", "CreateJob", fmt.Sprintf("Failed to create job: %s", err))
×
UNCOV
178
                        return ctrlResult, errLogAndWrap(log, err, "failed to create job")
×
UNCOV
179
                }
×
180
        } else if err != nil {
165✔
181
                return ctrlResult, errLogAndWrap(log, err, "could not get job")
×
182
        }
×
183

184
        // Update Status
185
        if changed := r.updateResourceStatusFromJob(ctx, res, job); changed {
271✔
186
                if err := r.Status().Update(ctx, res); err != nil {
66✔
187
                        return ctrlResult, errLogAndWrap(log, err, "failed to update status")
7✔
188
                }
7✔
189
        }
190

191
        if !isJobComplete(job) {
305✔
192
                log.V(1).Info("Job is still running, requeue after 5 seconds")
100✔
193
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
100✔
194
        }
100✔
195

196
        ttlDuration := time.Duration(ttlSeconds(res.Spec.FailedJobTTL)) * time.Second
105✔
197

105✔
198
        switch {
105✔
199
        case job.Status.Succeeded > 0:
3✔
200
                cleanupRenderResources(ctx, r, res, job)
3✔
201
                log.V(1).Info("Cleaned up after successful job")
3✔
202

3✔
203
                return ctrlResult, nil
3✔
204

205
        case job.Status.Failed > 0:
102✔
206
                if shouldCleanupSecrets(res, ttlDuration) {
200✔
207
                        cleanupSecrets(ctx, r, res)
98✔
208
                        log.V(1).Info("Cleaned up secrets after failed job TTL")
98✔
209

98✔
210
                        return ctrlResult, nil
98✔
211
                }
98✔
212
                remaining := remainingTTL(res, ttlDuration)
4✔
213
                log.V(1).Info("Waiting for TTL to expire before cleaning up secrets", "remainingSeconds", remaining.Seconds())
4✔
214

4✔
215
                return ctrl.Result{RequeueAfter: remaining + time.Second}, nil
4✔
216
        }
217

218
        return ctrlResult, nil
×
219
}
220

221
// updateResourceStatusFromJob updates the resource status based on job status
222
func (r *RenderTaskReconciler) updateResourceStatusFromJob(ctx context.Context, res *solarv1alpha1.RenderTask, job *batchv1.Job) (changed bool) {
212✔
223
        log := ctrl.LoggerFrom(ctx)
212✔
224

212✔
225
        if job == nil {
212✔
226
                changed = apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
×
227
                        Type:               ConditionTypeJobScheduled,
×
228
                        Status:             metav1.ConditionFalse,
×
229
                        ObservedGeneration: res.Generation,
×
230
                        Reason:             "DoesNotExist",
×
231
                        Message:            "Renderer job does not exist",
×
232
                })
×
233

×
234
                return changed
×
235
        }
×
236

237
        if job.Status.Succeeded > 0 {
215✔
238
                changed = apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
3✔
239
                        Type:               ConditionTypeJobSucceeded,
3✔
240
                        Status:             metav1.ConditionTrue,
3✔
241
                        ObservedGeneration: res.Generation,
3✔
242
                        Reason:             "JobSucceeded",
3✔
243
                        Message:            fmt.Sprintf("Renderer job completed successfully at %v", job.Status.CompletionTime),
3✔
244
                })
3✔
245

3✔
246
                if res.Status.ChartURL != r.referenceURL(res.Spec.Repository, res.Spec.Tag) {
6✔
247
                        res.Status.ChartURL = r.referenceURL(res.Spec.Repository, res.Spec.Tag)
3✔
248
                        changed = true
3✔
249
                }
3✔
250

251
                r.Recorder.Eventf(res, job, corev1.EventTypeNormal, "JobSucceeded", "RunJob", "Renderer job completed successfully")
3✔
252
                log.V(1).Info("Job succeeded", "name", job.Name)
3✔
253

3✔
254
                return changed
3✔
255
        }
256

257
        if job.Status.Failed > 0 {
311✔
258
                changed = apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
102✔
259
                        Type:               ConditionTypeJobFailed,
102✔
260
                        Status:             metav1.ConditionTrue,
102✔
261
                        ObservedGeneration: res.Generation,
102✔
262
                        Reason:             "JobFailed",
102✔
263
                        Message:            "Renderer job failed",
102✔
264
                })
102✔
265
                r.Recorder.Eventf(res, job, corev1.EventTypeWarning, "JobFailed", "RunJob", "Renderer job failed")
102✔
266
                log.V(1).Info("Job failed", "name", job.Name)
102✔
267

102✔
268
                return changed
102✔
269
        }
102✔
270

271
        return apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
107✔
272
                Type:               ConditionTypeJobScheduled,
107✔
273
                Status:             metav1.ConditionTrue,
107✔
274
                ObservedGeneration: res.Generation,
107✔
275
                Reason:             "JobScheduled",
107✔
276
                Message:            fmt.Sprintf("Renderer job is running (active: %d, succeeded: %d, failed: %d)", job.Status.Active, job.Status.Succeeded, job.Status.Failed),
107✔
277
        })
107✔
278
}
279

280
func (r *RenderTaskReconciler) deleteAuthSecret(ctx context.Context, res *solarv1alpha1.RenderTask) error {
112✔
281
        secret := &corev1.Secret{}
112✔
282
        if err := r.Get(ctx, r.authSecretKey(res), secret); err != nil {
113✔
283
                return err
1✔
284
        }
1✔
285

286
        return r.Delete(ctx, secret, client.PropagationPolicy(metav1.DeletePropagationBackground))
111✔
287
}
288

289
func (r *RenderTaskReconciler) deleteRenderJob(ctx context.Context, res *solarv1alpha1.RenderTask) error {
14✔
290
        job := &batchv1.Job{}
14✔
291
        if err := r.Get(ctx, r.renderJobKey(res), job); err != nil {
14✔
UNCOV
292
                return err
×
UNCOV
293
        }
×
294

295
        return r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground))
14✔
296
}
297

298
func (r *RenderTaskReconciler) deleteConfigSecret(ctx context.Context, res *solarv1alpha1.RenderTask) error {
112✔
299
        secret := &corev1.Secret{}
112✔
300
        if err := r.Get(ctx, r.configSecretKey(res), secret); err != nil {
112✔
UNCOV
301
                return err
×
UNCOV
302
        }
×
303

304
        return r.Delete(ctx, secret, client.PropagationPolicy(metav1.DeletePropagationBackground))
112✔
305
}
306

307
func (r *RenderTaskReconciler) copyAuthSecret(ctx context.Context, res *solarv1alpha1.RenderTask) (*corev1.Secret, error) {
144✔
308
        log := ctrl.LoggerFrom(ctx)
144✔
309

144✔
310
        controllerSecret := &corev1.Secret{}
144✔
311
        if err := r.Get(ctx, client.ObjectKey{Name: r.PushSecretRef.Name, Namespace: r.PushSecretRef.Namespace}, controllerSecret); err != nil {
144✔
312
                return nil, err
×
313
        }
×
314

315
        authSecret := &corev1.Secret{
144✔
316
                ObjectMeta: metav1.ObjectMeta{
144✔
317
                        Name:      r.authSecretKey(res).Name,
144✔
318
                        Namespace: r.authSecretKey(res).Namespace,
144✔
319
                },
144✔
320
                Type:       controllerSecret.Type,
144✔
321
                Data:       controllerSecret.Data,
144✔
322
                StringData: controllerSecret.StringData,
144✔
323
        }
144✔
324

144✔
325
        if err := r.Create(ctx, authSecret); err != nil {
147✔
326
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreationFailed", "Create", "Failed to create secret: %s", err)
3✔
327
                return nil, errLogAndWrap(log, err, "secret creation failed")
3✔
328
        }
3✔
329

330
        // Set owner references
331
        if err := controllerutil.SetControllerReference(res, authSecret, r.Scheme); err != nil {
141✔
332
                return nil, errLogAndWrap(log, err, "failed to set controller reference")
×
333
        }
×
334

335
        return authSecret, nil
141✔
336
}
337

338
func (r *RenderTaskReconciler) createRenderJob(ctx context.Context, res *solarv1alpha1.RenderTask, configSecret, authSecret *corev1.Secret) error {
47✔
339
        log := ctrl.LoggerFrom(ctx)
47✔
340

47✔
341
        jobName := r.renderJobKey(res).Name
47✔
342
        backoffLimit := int32(3)
47✔
343
        ttlSecondsAfterFinished := int32(3600)
47✔
344
        if res.Spec.FailedJobTTL != nil {
50✔
345
                ttlSecondsAfterFinished = *res.Spec.FailedJobTTL
3✔
346
        }
3✔
347

348
        volumes := []corev1.Volume{
47✔
349
                {
47✔
350
                        Name: "config",
47✔
351
                        VolumeSource: corev1.VolumeSource{
47✔
352
                                Secret: &corev1.SecretVolumeSource{
47✔
353
                                        SecretName: configSecret.Name,
47✔
354
                                        Items: []corev1.KeyToPath{
47✔
355
                                                {
47✔
356
                                                        Key:  "config.json",
47✔
357
                                                        Path: "config.json",
47✔
358
                                                },
47✔
359
                                        },
47✔
360
                                },
47✔
361
                        },
47✔
362
                },
47✔
363
        }
47✔
364
        volumeMounts := []corev1.VolumeMount{
47✔
365
                {
47✔
366
                        Name:      "config",
47✔
367
                        MountPath: "/etc/renderer/config.json",
47✔
368
                        SubPath:   "config.json",
47✔
369
                        ReadOnly:  true,
47✔
370
                },
47✔
371
        }
47✔
372
        envVars := []corev1.EnvVar{
47✔
373
                {
47✔
374
                        Name: "POD_NAMESPACE",
47✔
375
                        ValueFrom: &corev1.EnvVarSource{
47✔
376
                                FieldRef: &corev1.ObjectFieldSelector{
47✔
377
                                        FieldPath: "metadata.namespace",
47✔
378
                                },
47✔
379
                        },
47✔
380
                },
47✔
381
                {
47✔
382
                        Name: "POD_NAME",
47✔
383
                        ValueFrom: &corev1.EnvVarSource{
47✔
384
                                FieldRef: &corev1.ObjectFieldSelector{
47✔
385
                                        FieldPath: "metadata.name",
47✔
386
                                },
47✔
387
                        },
47✔
388
                },
47✔
389
        }
47✔
390

47✔
391
        if r.RendererCAConfigMap != "" {
94✔
392
                volumes = append(volumes, corev1.Volume{
47✔
393
                        Name: "ca-bundle",
47✔
394
                        VolumeSource: corev1.VolumeSource{
47✔
395
                                ConfigMap: &corev1.ConfigMapVolumeSource{
47✔
396
                                        LocalObjectReference: corev1.LocalObjectReference{
47✔
397
                                                Name: r.RendererCAConfigMap,
47✔
398
                                        },
47✔
399
                                        Items: []corev1.KeyToPath{
47✔
400
                                                {
47✔
401
                                                        Key:  "trust-bundle.pem",
47✔
402
                                                        Path: "ca-bundle.pem",
47✔
403
                                                },
47✔
404
                                        },
47✔
405
                                },
47✔
406
                        },
47✔
407
                })
47✔
408
                volumeMounts = append(volumeMounts, corev1.VolumeMount{
47✔
409
                        Name:      "ca-bundle",
47✔
410
                        MountPath: "/etc/ssl/certs",
47✔
411
                        ReadOnly:  true,
47✔
412
                })
47✔
413
                envVars = append(envVars, corev1.EnvVar{
47✔
414
                        Name:  "SSL_CERT_FILE",
47✔
415
                        Value: "/etc/ssl/certs/ca-bundle.pem",
47✔
416
                })
47✔
417
        }
47✔
418

419
        job := &batchv1.Job{
47✔
420
                ObjectMeta: metav1.ObjectMeta{
47✔
421
                        Name:      jobName,
47✔
422
                        Namespace: r.renderJobKey(res).Namespace,
47✔
423
                        OwnerReferences: []metav1.OwnerReference{
47✔
424
                                {
47✔
425
                                        APIVersion:         solarv1alpha1.SchemeGroupVersion.String(),
47✔
426
                                        Kind:               res.Kind,
47✔
427
                                        Name:               res.Name,
47✔
428
                                        UID:                res.GetUID(),
47✔
429
                                        Controller:         new(true),
47✔
430
                                        BlockOwnerDeletion: new(true),
47✔
431
                                },
47✔
432
                        },
47✔
433
                        Annotations: map[string]string{
47✔
434
                                annotationJobName: jobName,
47✔
435
                        },
47✔
436
                },
47✔
437
                Spec: batchv1.JobSpec{
47✔
438
                        BackoffLimit:            &backoffLimit,
47✔
439
                        TTLSecondsAfterFinished: &ttlSecondsAfterFinished,
47✔
440
                        Template: corev1.PodTemplateSpec{
47✔
441
                                Spec: corev1.PodSpec{
47✔
442
                                        RestartPolicy: corev1.RestartPolicyNever,
47✔
443
                                        Containers: []corev1.Container{
47✔
444
                                                {
47✔
445
                                                        Name:    "renderer",
47✔
446
                                                        Image:   r.RendererImage,
47✔
447
                                                        Command: []string{r.RendererCommand},
47✔
448
                                                        Args: append(r.RendererArgs,
47✔
449
                                                                "/etc/renderer/config.json",
47✔
450
                                                                fmt.Sprintf("--url=%s", r.referenceURL(res.Spec.Repository, res.Spec.Tag)),
47✔
451
                                                        ),
47✔
452
                                                        Env:          envVars,
47✔
453
                                                        VolumeMounts: volumeMounts,
47✔
454
                                                },
47✔
455
                                        },
47✔
456
                                        Volumes: volumes,
47✔
457
                                },
47✔
458
                        },
47✔
459
                },
47✔
460
        }
47✔
461

47✔
462
        if authSecret != nil {
92✔
463
                switch authSecret.Type {
45✔
464
                case corev1.SecretTypeBasicAuth:
1✔
465
                        job.Spec.Template.Spec.Containers[0].Env = append(job.Spec.Template.Spec.Containers[0].Env,
1✔
466
                                corev1.EnvVar{
1✔
467
                                        Name: "REGISTRY_USERNAME",
1✔
468
                                        ValueFrom: &corev1.EnvVarSource{
1✔
469
                                                SecretKeyRef: &corev1.SecretKeySelector{
1✔
470
                                                        LocalObjectReference: corev1.LocalObjectReference{
1✔
471
                                                                Name: authSecret.Name,
1✔
472
                                                        },
1✔
473
                                                        Key: "username",
1✔
474
                                                },
1✔
475
                                        },
1✔
476
                                },
1✔
477
                                corev1.EnvVar{
1✔
478
                                        Name: "REGISTRY_PASSWORD",
1✔
479
                                        ValueFrom: &corev1.EnvVarSource{
1✔
480
                                                SecretKeyRef: &corev1.SecretKeySelector{
1✔
481
                                                        LocalObjectReference: corev1.LocalObjectReference{
1✔
482
                                                                Name: authSecret.Name,
1✔
483
                                                        },
1✔
484
                                                        Key: "password",
1✔
485
                                                },
1✔
486
                                        },
1✔
487
                                },
1✔
488
                        )
1✔
489

490
                case corev1.SecretTypeDockerConfigJson:
23✔
491
                        job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{
23✔
492
                                Name: "dockerconfig",
23✔
493
                                VolumeSource: corev1.VolumeSource{
23✔
494
                                        Secret: &corev1.SecretVolumeSource{
23✔
495
                                                SecretName: authSecret.Name,
23✔
496
                                                Items: []corev1.KeyToPath{
23✔
497
                                                        {
23✔
498
                                                                Key:  ".dockerconfigjson",
23✔
499
                                                                Path: "dockerconfig.json",
23✔
500
                                                        },
23✔
501
                                                },
23✔
502
                                        },
23✔
503
                                },
23✔
504
                        })
23✔
505

23✔
506
                        job.Spec.Template.Spec.Containers[0].VolumeMounts = append(job.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{
23✔
507
                                Name:      "dockerconfig",
23✔
508
                                MountPath: "/etc/renderer/dockerconfig.json",
23✔
509
                                SubPath:   "dockerconfig.json",
23✔
510
                                ReadOnly:  true,
23✔
511
                        })
23✔
512

23✔
513
                        job.Spec.Template.Spec.Containers[0].Env = append(job.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{
23✔
514
                                Name:  "DOCKER_CONFIG",
23✔
515
                                Value: "/etc/renderer/dockerconfig.json",
23✔
516
                        })
23✔
517
                default:
21✔
518
                }
519
        }
520

521
        if err := r.Create(ctx, job); err != nil {
49✔
522
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreationFailed", "Create", "Failed to create job: %s", err)
2✔
523
                return errLogAndWrap(log, err, "job creation failed")
2✔
524
        }
2✔
525

526
        // Set owner references
527
        if err := controllerutil.SetControllerReference(res, job, r.Scheme); err != nil {
45✔
528
                return errLogAndWrap(log, err, "failed to set controller reference")
×
529
        }
×
530

531
        res.Status.JobRef = &corev1.ObjectReference{
45✔
532
                APIVersion: batchv1.SchemeGroupVersion.String(),
45✔
533
                Kind:       "Job",
45✔
534
                Namespace:  job.Namespace,
45✔
535
                Name:       job.Name,
45✔
536
        }
45✔
537

45✔
538
        if err := r.Status().Update(ctx, res); err != nil {
45✔
UNCOV
539
                return errLogAndWrap(log, err, "failed to update status")
×
UNCOV
540
        }
×
541

542
        return nil
45✔
543
}
544

545
func (r *RenderTaskReconciler) createConfigSecret(ctx context.Context, res *solarv1alpha1.RenderTask) (*corev1.Secret, error) {
144✔
546
        log := ctrl.LoggerFrom(ctx)
144✔
547

144✔
548
        cfgJson, err := json.Marshal(res.Spec.RendererConfig)
144✔
549
        if err != nil {
144✔
550
                return nil, err
×
551
        }
×
552

553
        secret := &corev1.Secret{
144✔
554
                ObjectMeta: metav1.ObjectMeta{
144✔
555
                        Name:      r.configSecretKey(res).Name,
144✔
556
                        Namespace: r.configSecretKey(res).Namespace,
144✔
557
                        OwnerReferences: []metav1.OwnerReference{
144✔
558
                                {
144✔
559
                                        APIVersion:         solarv1alpha1.SchemeGroupVersion.String(),
144✔
560
                                        Kind:               res.Kind,
144✔
561
                                        Name:               res.Name,
144✔
562
                                        UID:                res.UID,
144✔
563
                                        Controller:         new(true),
144✔
564
                                        BlockOwnerDeletion: new(true),
144✔
565
                                },
144✔
566
                        },
144✔
567
                        Annotations: map[string]string{
144✔
568
                                annotationSecretName: r.configSecretKey(res).Name,
144✔
569
                        },
144✔
570
                },
144✔
571
                Type: corev1.SecretTypeOpaque,
144✔
572
                Data: map[string][]byte{
144✔
573
                        "config.json": cfgJson,
144✔
574
                },
144✔
575
        }
144✔
576

144✔
577
        if err := r.Create(ctx, secret); err != nil {
144✔
578
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreationFailed", "Create", "Failed to create secret: %s", err)
×
579
                return nil, errLogAndWrap(log, err, "secret creation failed")
×
580
        }
×
581

582
        // Set owner references
583
        if err := controllerutil.SetControllerReference(res, secret, r.Scheme); err != nil {
144✔
584
                return nil, errLogAndWrap(log, err, "failed to set controller reference")
×
585
        }
×
586

587
        res.Status.ConfigSecretRef = &corev1.ObjectReference{
144✔
588
                APIVersion: corev1.SchemeGroupVersion.String(),
144✔
589
                Kind:       "Secret",
144✔
590
                Namespace:  secret.Namespace,
144✔
591
                Name:       secret.Name,
144✔
592
        }
144✔
593

144✔
594
        if err := r.Status().Update(ctx, res); err != nil {
144✔
595
                return nil, errLogAndWrap(log, err, "failed to update status")
×
596
        }
×
597

598
        return secret, nil
144✔
599
}
600

601
func (r *RenderTaskReconciler) configSecretKey(res *solarv1alpha1.RenderTask) client.ObjectKey {
756✔
602
        return client.ObjectKey{
756✔
603
                Name:      fmt.Sprintf("render-%s", res.Name),
756✔
604
                Namespace: res.Namespace,
756✔
605
        }
756✔
606
}
756✔
607

608
func (r *RenderTaskReconciler) authSecretKey(res *solarv1alpha1.RenderTask) client.ObjectKey {
612✔
609
        return client.ObjectKey{
612✔
610
                Name:      fmt.Sprintf("auth-%s", res.Name),
612✔
611
                Namespace: res.Namespace,
612✔
612
        }
612✔
613
}
612✔
614

615
func (r *RenderTaskReconciler) renderJobKey(res *solarv1alpha1.RenderTask) client.ObjectKey {
320✔
616
        return client.ObjectKey{
320✔
617
                Name:      fmt.Sprintf("render-%s", res.Name),
320✔
618
                Namespace: res.Namespace,
320✔
619
        }
320✔
620
}
320✔
621

622
// isJobComplete returns true if the Job is complete (succeeded or failed)
623
func isJobComplete(job *batchv1.Job) bool {
205✔
624
        return job.Status.CompletionTime != nil || job.Status.Failed > 0
205✔
625
}
205✔
626

627
func (r *RenderTaskReconciler) referenceURL(repo string, tag string) string {
53✔
628
        base := r.BaseURL
53✔
629
        if !strings.HasPrefix(base, "oci://") {
106✔
630
                base = fmt.Sprintf("oci://%s", base)
53✔
631
        }
53✔
632
        base = strings.TrimSuffix(base, "/")
53✔
633
        url := fmt.Sprintf("%s/%s:%s", base, repo, tag)
53✔
634

53✔
635
        return url
53✔
636
}
637

638
func ttlSeconds(ttl *int32) int32 {
105✔
639
        if ttl != nil {
205✔
640
                return *ttl
100✔
641
        }
100✔
642

643
        return 3600
5✔
644
}
645

646
func shouldCleanupSecrets(res *solarv1alpha1.RenderTask, ttl time.Duration) bool {
102✔
647
        cond := apimeta.FindStatusCondition(res.Status.Conditions, ConditionTypeJobFailed)
102✔
648
        return cond != nil && time.Since(cond.LastTransitionTime.Time) >= ttl
102✔
649
}
102✔
650

651
func remainingTTL(res *solarv1alpha1.RenderTask, ttl time.Duration) time.Duration {
4✔
652
        cond := apimeta.FindStatusCondition(res.Status.Conditions, ConditionTypeJobFailed)
4✔
653
        if cond == nil {
4✔
NEW
654
                return ttl
×
NEW
655
        }
×
656
        remaining := ttl - time.Since(cond.LastTransitionTime.Time)
4✔
657
        if remaining < 0 {
4✔
NEW
658
                return 0
×
NEW
659
        }
×
660

661
        return remaining
4✔
662
}
663

664
func cleanupSecrets(ctx context.Context, r *RenderTaskReconciler, res *solarv1alpha1.RenderTask) {
101✔
665
        if err := r.deleteConfigSecret(ctx, res); err != nil && !apierrors.IsNotFound(err) {
101✔
NEW
666
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "DeletionFailed", "Delete", "Failed to delete config secret", err)
×
NEW
667
        }
×
668
        if err := r.deleteAuthSecret(ctx, res); err != nil && !apierrors.IsNotFound(err) {
101✔
NEW
669
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "DeletionFailed", "Delete", "Failed to delete auth secret", err)
×
NEW
670
        }
×
671
}
672

673
func cleanupRenderResources(ctx context.Context, r *RenderTaskReconciler, res *solarv1alpha1.RenderTask, job *batchv1.Job) {
3✔
674
        cleanupSecrets(ctx, r, res)
3✔
675
        if err := r.deleteRenderJob(ctx, res); err != nil && !apierrors.IsNotFound(err) {
3✔
NEW
676
                r.Recorder.Eventf(res, job, corev1.EventTypeWarning, "DeletionFailed", "Delete", "Failed to delete job", err)
×
NEW
677
        }
×
678
}
679

680
// SetupWithManager sets up the controller with the Manager.
681
func (r *RenderTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
682
        return ctrl.NewControllerManagedBy(mgr).
1✔
683
                For(&solarv1alpha1.RenderTask{}).
1✔
684
                Owns(&batchv1.Job{}).
1✔
685
                Owns(&corev1.Secret{}).
1✔
686
                Complete(r)
1✔
687
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc