• 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

76.33
/pkg/controller/target_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
        "fmt"
9
        "slices"
10

11
        corev1 "k8s.io/api/core/v1"
12
        apiequality "k8s.io/apimachinery/pkg/api/equality"
13
        apierrors "k8s.io/apimachinery/pkg/api/errors"
14
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15
        "k8s.io/apimachinery/pkg/labels"
16
        "k8s.io/apimachinery/pkg/runtime"
17
        "k8s.io/apimachinery/pkg/types"
18
        "k8s.io/client-go/tools/events"
19
        ctrl "sigs.k8s.io/controller-runtime"
20
        "sigs.k8s.io/controller-runtime/pkg/builder"
21
        "sigs.k8s.io/controller-runtime/pkg/client"
22
        "sigs.k8s.io/controller-runtime/pkg/event"
23
        "sigs.k8s.io/controller-runtime/pkg/handler"
24
        "sigs.k8s.io/controller-runtime/pkg/predicate"
25
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
26

27
        solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1"
28
)
29

30
const (
31
        targetFinalizer = "solar.opendefense.cloud/target-finalizer"
32
)
33

34
type TargetReconciler struct {
35
        client.Client
36
        Scheme   *runtime.Scheme
37
        Recorder events.EventRecorder
38
        // WatchNamespace restricts reconciliation to this namespace.
39
        // Should be empty in production (watches all namespaces).
40
        // Intended for use in integration tests only.
41
        // See: https://book.kubebuilder.io/reference/envtest#testing-considerations
42
        WatchNamespace string
43
}
44

45
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=targets/status,verbs=get;update;patch
46
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=targets/finalizers,verbs=update
47
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=targets,verbs=get;list;watch;create;update;patch;delete
48
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets,verbs=get;list;watch;create;update;patch;delete
49
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=profiles,verbs=get;list;watch
50
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
51
//+kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
52

53
// Reconcile moves the current state of the cluster closer to the desired state
54
func (r *TargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
77✔
55
        log := ctrl.LoggerFrom(ctx)
77✔
56
        ctrlResult := ctrl.Result{}
77✔
57

77✔
58
        log.V(1).Info("Target is being reconciled", "req", req)
77✔
59

77✔
60
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
78✔
61
                return ctrl.Result{}, nil
1✔
62
        }
1✔
63

64
        // Fetch target
65
        target := &solarv1alpha1.Target{}
76✔
66
        if err := r.Get(ctx, req.NamespacedName, target); err != nil {
77✔
67
                if apierrors.IsNotFound(err) {
2✔
68
                        return ctrlResult, nil
1✔
69
                }
1✔
70

71
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
72
        }
73

74
        // Handle deletion
75
        if !target.DeletionTimestamp.IsZero() {
76✔
76
                log.V(1).Info("Target is being deleted")
1✔
77
                r.Recorder.Eventf(target, nil, corev1.EventTypeWarning, "Deleting", "Reconcile", "Target is being deleted, cleaning up HydratedTarget")
1✔
78

1✔
79
                // Delete HydratedTarget
1✔
80
                if err := r.Delete(ctx, &solarv1alpha1.HydratedTarget{ObjectMeta: metav1.ObjectMeta{Namespace: target.Namespace, Name: target.Name}}); err != nil && !apierrors.IsNotFound(err) {
1✔
81
                        return ctrlResult, errLogAndWrap(log, err, "failed to delete HydratedTarget")
×
82
                }
×
83

84
                // Remove finalizer
85
                if slices.Contains(target.Finalizers, targetFinalizer) {
2✔
86
                        // Re-fetch latest version to avoid conflicts
1✔
87
                        latest := &solarv1alpha1.Target{}
1✔
88
                        if err := r.Get(ctx, req.NamespacedName, latest); err != nil {
1✔
UNCOV
89
                                return ctrlResult, errLogAndWrap(log, err, "failed to get latest Target for finalizer removal")
×
UNCOV
90
                        }
×
91
                        log.V(1).Info("Removing finalizer from Target")
1✔
92
                        original := latest.DeepCopy()
1✔
93
                        latest.Finalizers = slices.DeleteFunc(latest.Finalizers, func(s string) bool {
2✔
94
                                return s == targetFinalizer
1✔
95
                        })
1✔
96

97
                        if err := r.Patch(ctx, latest, client.MergeFrom(original)); err != nil {
1✔
98
                                return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer from Target")
×
99
                        }
×
100
                }
101

102
                return ctrlResult, nil
1✔
103
        }
104

105
        // Set finalizer if not set already and not currently deleting
106
        if target.DeletionTimestamp.IsZero() && !slices.Contains(target.Finalizers, targetFinalizer) {
86✔
107
                log.V(1).Info("Target does not have finalizer set, adding finalizer")
12✔
108
                latest := &solarv1alpha1.Target{}
12✔
109
                if err := r.Get(ctx, req.NamespacedName, latest); err != nil {
12✔
110
                        return ctrlResult, errLogAndWrap(log, err, "failed to get latest Target for finalizer addition")
×
111
                }
×
112
                original := latest.DeepCopy()
12✔
113
                latest.Finalizers = append(latest.Finalizers, targetFinalizer)
12✔
114
                if err := r.Patch(ctx, latest, client.MergeFrom(original)); err != nil {
12✔
115
                        return ctrlResult, errLogAndWrap(log, err, "failed to add finalizer to Target")
×
116
                }
×
117

118
                return ctrlResult, nil
12✔
119
        }
120

121
        // Get matching profiles
122
        profileList := &solarv1alpha1.ProfileList{}
62✔
123
        if err := r.List(ctx, profileList, client.InNamespace(target.Namespace)); err != nil {
62✔
124
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to list Profiles")
×
125
        }
×
126

127
        matchingProfiles := make(map[string]corev1.LocalObjectReference)
62✔
128
        targetLabels := labels.Set(target.Labels)
62✔
129

62✔
130
        for _, profile := range profileList.Items {
104✔
131
                selector, err := metav1.LabelSelectorAsSelector(&profile.Spec.TargetSelector)
42✔
132
                if err != nil {
42✔
133
                        log.Error(err, "invalid targetSelector in Profile; skipping")
×
134
                        continue
×
135
                }
136

137
                if selector.Matches(targetLabels) {
71✔
138
                        matchingProfiles[profile.Name] = corev1.LocalObjectReference{Name: profile.Name}
29✔
139
                }
29✔
140
        }
141

142
        // Check if hydrated target exists, if not create and make sure to SetControllerReference...
143
        hydratedTarget := &solarv1alpha1.HydratedTarget{}
62✔
144
        err := r.Get(ctx, req.NamespacedName, hydratedTarget)
62✔
145

62✔
146
        if err != nil && !apierrors.IsNotFound(err) {
62✔
147
                return ctrlResult, errLogAndWrap(log, err, "failed to get HydratedTarget")
×
148
        }
×
149

150
        // Create HydratedTarget if not exists or update/override spec
151
        if apierrors.IsNotFound(err) {
74✔
152
                log.V(1).Info("Creating HydratedTarget for Target", "target", req.NamespacedName)
12✔
153
                hydratedTarget = &solarv1alpha1.HydratedTarget{
12✔
154
                        ObjectMeta: metav1.ObjectMeta{
12✔
155
                                Name:      target.Name,
12✔
156
                                Namespace: target.Namespace,
12✔
157
                        },
12✔
158
                        Spec: solarv1alpha1.HydratedTargetSpec{
12✔
159
                                Releases: target.Spec.Releases,
12✔
160
                                Profiles: matchingProfiles,
12✔
161
                                Userdata: target.Spec.Userdata,
12✔
162
                        },
12✔
163
                }
12✔
164
                if err := ctrl.SetControllerReference(target, hydratedTarget, r.Scheme); err != nil {
12✔
165
                        return ctrlResult, errLogAndWrap(log, err, "failed to set controller reference on HydratedTarget")
×
166
                }
×
167
                if err := r.Create(ctx, hydratedTarget); err != nil {
12✔
168
                        if !apierrors.IsAlreadyExists(err) {
×
169
                                return ctrlResult, errLogAndWrap(log, err, "failed to create HydratedTarget")
×
170
                        }
×
171
                        log.V(1).Info("HydratedTarget already exists, will update", "hydratedTarget", req.NamespacedName)
×
172
                } else {
12✔
173
                        r.Recorder.Eventf(target, nil, corev1.EventTypeNormal, "Created", "Create", "Created HydratedTarget %s/%s", hydratedTarget.Namespace, hydratedTarget.Name)
12✔
174
                        return ctrlResult, nil
12✔
175
                }
12✔
176
        }
177

178
        // Update if out of sync
179
        // re-fetch target and hydratedTarget to avoid conflicts
180
        hydratedTarget = &solarv1alpha1.HydratedTarget{}
50✔
181
        if err := r.Get(ctx, req.NamespacedName, hydratedTarget); err != nil {
50✔
182
                return ctrlResult, errLogAndWrap(log, err, "failed to re-fetch HydratedTarget for update check")
×
183
        }
×
184
        target = &solarv1alpha1.Target{}
50✔
185
        if err := r.Get(ctx, req.NamespacedName, target); err != nil {
50✔
186
                return ctrlResult, errLogAndWrap(log, err, "failed to re-fetch Target for update check")
×
187
        }
×
188

189
        original := hydratedTarget.DeepCopy()
50✔
190

50✔
191
        hydratedTarget.Spec.Releases = target.Spec.Releases
50✔
192
        hydratedTarget.Spec.Profiles = matchingProfiles
50✔
193
        hydratedTarget.Spec.Userdata = target.Spec.Userdata
50✔
194

50✔
195
        if !apiequality.Semantic.DeepEqual(original.Spec, hydratedTarget.Spec) {
59✔
196
                log.V(1).Info("Updating HydratedTarget for Target", "target", req.NamespacedName)
9✔
197
                if err := r.Patch(ctx, hydratedTarget, client.MergeFrom(original)); err != nil {
9✔
198
                        return ctrlResult, errLogAndWrap(log, err, "failed to update HydratedTarget")
×
199
                }
×
200
                r.Recorder.Eventf(target, nil, corev1.EventTypeNormal, "Updated", "Update", "Updated HydratedTarget %s/%s", hydratedTarget.Namespace, hydratedTarget.Name)
9✔
201
        }
202

203
        return ctrl.Result{}, nil
50✔
204
}
205

206
// SetupWithManager sets up the controller with the Manager.
207
func (r *TargetReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
208
        return ctrl.NewControllerManagedBy(mgr).
1✔
209
                For(&solarv1alpha1.Target{}).
1✔
210
                Owns(&solarv1alpha1.HydratedTarget{}).
1✔
211
                Watches(
1✔
212
                        &solarv1alpha1.Profile{},
1✔
213
                        handler.EnqueueRequestsFromMapFunc(r.mapProfileToTargets),
1✔
214
                        builder.WithPredicates(profileSelectionPredicate()),
1✔
215
                ).
1✔
216
                Complete(r)
1✔
217
}
1✔
218

219
// profileSelectionPredicate filters events to only trigger reconciles when the target selector of a profile changes.
220
func profileSelectionPredicate() predicate.Predicate {
5✔
221
        return predicate.Funcs{
5✔
222
                UpdateFunc: func(e event.UpdateEvent) bool {
8✔
223
                        oldObj, ok1 := e.ObjectOld.(*solarv1alpha1.Profile)
3✔
224
                        newObj, ok2 := e.ObjectNew.(*solarv1alpha1.Profile)
3✔
225
                        if !ok1 || !ok2 {
3✔
226
                                return false
×
227
                        }
×
228

229
                        return !apiequality.Semantic.DeepEqual(oldObj.Spec.TargetSelector, newObj.Spec.TargetSelector)
3✔
230
                },
231
        }
232
}
233

234
// mapProfileToTargets maps a Profile to a list of Target reconcile requests.
235
func (r *TargetReconciler) mapProfileToTargets(ctx context.Context, obj client.Object) []reconcile.Request {
9✔
236
        log := ctrl.LoggerFrom(ctx)
9✔
237

9✔
238
        profile, ok := obj.(*solarv1alpha1.Profile)
9✔
239
        if !ok {
9✔
240
                log.Error(nil, "Object is not a Profile", "type", fmt.Sprintf("%T", obj))
×
241
                return nil
×
242
        }
×
243

244
        selector, err := metav1.LabelSelectorAsSelector(&profile.Spec.TargetSelector)
9✔
245
        if err != nil {
9✔
246
                log.Error(err, "Invalid targetSelector in Profile", "profile", profile.Name, "targetSelector", profile.Spec.TargetSelector.String())
×
247
                return nil
×
248
        }
×
249

250
        targetList := &solarv1alpha1.TargetList{}
9✔
251
        err = r.List(ctx, targetList,
9✔
252
                client.InNamespace(profile.GetNamespace()),
9✔
253
                client.MatchingLabelsSelector{Selector: selector},
9✔
254
        )
9✔
255
        if err != nil {
9✔
256
                log.V(1).Error(err, "Failed to list Targets for Profile", "profile", profile.Name)
×
257
                return nil
×
258
        }
×
259

260
        requests := make([]reconcile.Request, 0, len(targetList.Items))
9✔
261
        for _, target := range targetList.Items {
16✔
262
                requests = append(requests, reconcile.Request{
7✔
263
                        NamespacedName: types.NamespacedName{
7✔
264
                                Name:      target.Name,
7✔
265
                                Namespace: target.Namespace,
7✔
266
                        },
7✔
267
                })
7✔
268
        }
7✔
269

270
        return requests
9✔
271
}
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