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

opendefensecloud / solution-arsenal / 23485901803

24 Mar 2026 10:56AM UTC coverage: 71.716% (-0.007%) from 71.723%
23485901803

push

github

web-flow
fix(controller): filter reconciliation by namespace and fix EnvTest test errors (#323)

* fix(controller): add WatchNamespace field to reconcilers to fix EnvTest namespace errors

EnvTest does not support namespace deletion - namespaces are only marked for
termination. When running all controller tests together, reconcilers continue
reconciling objects from terminating namespaces, causing spurious errors.

This change adds a WatchNamespace field to all reconcilers. In tests, the field
is set per-test via BeforeEach/AfterEach to restrict reconciliation to only the
current test namespace. This prevents reconcilers from processing objects in
namespaces that are being cleaned up from previous tests.

The WatchNamespace field is empty by default (watches all namespaces), preserving
production behavior. This is intended for integration tests only.

See: https://book.kubebuilder.io/reference/envtest#testing-considerations

reduce diff-noise

* test: move auth secret creation to BeforeSuite

Move the rendertask-secret creation from BeforeAll in
rendertask_controller_test.go to BeforeSuite in suite_test.go.
This avoids duplicating the secret creation across test files and
ensures it's available for all controller tests.

* test(controller): suppress namespace terminating errors in errLogAndWrap

EnvTest marks namespaces for termination rather than deleting them, causing
reconcilers to fail with "unable to create new content in namespace because
it is being terminated" errors during test cleanup. These are not real bugs
but EnvTest limitations.

This change modifies errLogAndWrap to silently ignore errors containing
"because it is being terminated", suppressing the error noise while still
returning nil to allow reconciliation to complete.

See: https://book.kubebuilder.io/reference/envtest#testing-considerations

* chore: Consistent naming

* tests: Only suppress NamespaceTerminatingErrors while testing

* fix: isTestMode() instead of testMode var

The variable... (continued)

23 of 29 new or added lines in 6 files covered. (79.31%)

12 existing lines in 2 files now uncovered.

2178 of 3037 relevant lines covered (71.72%)

13.89 hits per line

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

87.56
/pkg/controller/hydratedtarget_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
        "net/url"
10
        "slices"
11
        "strings"
12
        "time"
13

14
        ociname "github.com/google/go-containerregistry/pkg/name"
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
        hydratedTargetFinalizer = "solar.opendefense.cloud/hydrated-target-finalizer"
30
)
31

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

44
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets,verbs=get;list;watch;create;update;patch;delete
45
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets/status,verbs=get;update;patch
46
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets/finalizers,verbs=update
47
// FIXME: Switch out releases for profiles                      👇
48
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases,verbs=get;list;watch
49
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=rendertasks,verbs=get;list;watch;create;update;patch;delete
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
//
55
// Reconciliation Flow:
56
//
57
//        HydratedTarget created
58
//            ↓
59
//        Add finalizer
60
//            ↓
61
//        Check if already succeeded → YES → Return (no-op)
62
//            ↓ NO
63
//        Get or create RenderTask
64
//            ↓
65
//        Update status from RenderTask
66

67
func (r *HydratedTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
93✔
68
        log := ctrl.LoggerFrom(ctx)
93✔
69
        ctrlResult := ctrl.Result{}
93✔
70

93✔
71
        log.V(1).Info("HydratedTarget is being reconciled", "req", req)
93✔
72

93✔
73
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
108✔
74
                return ctrlResult, nil
15✔
75
        }
15✔
76

77
        // Fetch the HydratedTarget instance
78
        res := &solarv1alpha1.HydratedTarget{}
78✔
79
        if err := r.Get(ctx, req.NamespacedName, res); err != nil {
80✔
80
                if apierrors.IsNotFound(err) {
4✔
81
                        // Object not found, return. Created objects are automatically garbage collected.
2✔
82
                        return ctrlResult, nil
2✔
83
                }
2✔
84

85
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
86
        }
87

88
        // Handle deletion: cleanup rendertask, then remove finalizer
89
        if !res.DeletionTimestamp.IsZero() {
78✔
90
                log.V(1).Info("HydratedTarget is being deleted")
2✔
91
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "Deleting", "Delete", "HydratedTarget is being deleted, cleaning up resources")
2✔
92

2✔
93
                if err := r.deleteRenderTask(ctx, res); client.IgnoreNotFound(err) != nil {
2✔
94
                        return ctrlResult, errLogAndWrap(log, err, "failed to delete render task")
×
95
                }
×
96

97
                // Remove finalizer
98
                if slices.Contains(res.Finalizers, hydratedTargetFinalizer) {
4✔
99
                        log.V(1).Info("Removing finalizer from resource")
2✔
100
                        res.Finalizers = slices.DeleteFunc(res.Finalizers, func(f string) bool {
4✔
101
                                return f == hydratedTargetFinalizer
2✔
102
                        })
2✔
103
                        if err := r.Update(ctx, res); err != nil {
2✔
104
                                return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer")
×
105
                        }
×
106
                }
107

108
                return ctrlResult, nil
2✔
109
        }
110

111
        // Add finalizer if not present and not deleting
112
        if res.DeletionTimestamp.IsZero() {
148✔
113
                if !slices.Contains(res.Finalizers, hydratedTargetFinalizer) {
93✔
114
                        log.V(1).Info("Adding finalizer to resource")
19✔
115
                        res.Finalizers = append(res.Finalizers, hydratedTargetFinalizer)
19✔
116
                        if err := r.Update(ctx, res); err != nil {
19✔
117
                                return ctrlResult, errLogAndWrap(log, err, "failed to add finalizer")
×
118
                        }
×
119
                        // Return without requeue; the Update event will trigger reconciliation again
120
                        return ctrlResult, nil
19✔
121
                }
122
        }
123

124
        // Check if rendertask has already completed successfully
125
        sc := apimeta.FindStatusCondition(res.Status.Conditions, ConditionTypeTaskCompleted)
55✔
126
        if sc != nil && sc.ObservedGeneration >= res.Generation && sc.Status == metav1.ConditionTrue {
56✔
127
                log.V(1).Info("RenderTask has already completed successfully, no further action needed")
1✔
128
                return ctrlResult, nil
1✔
129
        }
1✔
130

131
        // Check if rendertask has already failed
132
        fc := apimeta.FindStatusCondition(res.Status.Conditions, ConditionTypeTaskFailed)
54✔
133
        if fc != nil && fc.ObservedGeneration >= res.Generation && fc.Status == metav1.ConditionTrue {
55✔
134
                log.V(1).Info("RenderTask has already failed, no further action needed")
1✔
135
                return ctrlResult, nil
1✔
136
        }
1✔
137

138
        // Reconcile RenderTask
139
        rt := &solarv1alpha1.RenderTask{}
53✔
140
        err := r.Get(ctx, client.ObjectKey{Name: generationName(res), Namespace: res.Namespace}, rt)
53✔
141
        if client.IgnoreNotFound(err) != nil {
53✔
142
                return ctrlResult, errLogAndWrap(log, err, "failed to get RenderTask")
×
143
        }
×
144

145
        if apierrors.IsNotFound(err) {
83✔
146
                if err := r.createRenderTask(ctx, res); err != nil {
35✔
147
                        log.V(1).Error(err, "Failed to create RenderTask")
5✔
148
                        r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreationFailed", "Create", fmt.Sprintf("failed to create RenderTask: %q", err))
5✔
149

5✔
150
                        if apierrors.IsNotFound(err) {
9✔
151
                                return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
4✔
152
                        }
4✔
153

154
                        return ctrlResult, errLogAndWrap(log, err, "failed to create RenderTask")
1✔
155
                }
156
                log.V(1).Info("Created RenderTask", "res", res)
25✔
157
                r.Recorder.Eventf(res, rt, corev1.EventTypeNormal, "Created", "Create", "RenderTask was created")
25✔
158
        }
159

160
        if changed := r.updateStatusConditionsFromRenderTask(ctx, res, rt); changed {
50✔
161
                if err := r.Status().Update(ctx, res); err != nil {
2✔
162
                        return ctrlResult, errLogAndWrap(log, err, "failed to update status")
×
163
                }
×
164
        }
165

166
        // RenderTask still running, requeue
167
        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
48✔
168
}
169

170
func (r *HydratedTargetReconciler) updateStatusConditionsFromRenderTask(ctx context.Context, res *solarv1alpha1.HydratedTarget, rt *solarv1alpha1.RenderTask) (changed bool) {
48✔
171
        if rt == nil || res == nil {
48✔
172
                return false
×
173
        }
×
174

175
        log := ctrl.LoggerFrom(ctx)
48✔
176

48✔
177
        if apimeta.IsStatusConditionTrue(rt.Status.Conditions, ConditionTypeJobFailed) {
49✔
178
                changed = apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
1✔
179
                        Type:               ConditionTypeTaskFailed,
1✔
180
                        Status:             metav1.ConditionTrue,
1✔
181
                        ObservedGeneration: res.Generation,
1✔
182
                        Reason:             "TaskFailed",
1✔
183
                        Message:            "RenderTask failed",
1✔
184
                })
1✔
185

1✔
186
                log.V(1).Info("RenderTask failed", "name", rt.Name)
1✔
187
                r.Recorder.Eventf(res, rt, corev1.EventTypeWarning, "TaskFailed", "RunTask", "RenderTask failed")
1✔
188

1✔
189
                return changed
1✔
190
        }
1✔
191

192
        if apimeta.IsStatusConditionTrue(rt.Status.Conditions, ConditionTypeJobSucceeded) {
48✔
193
                changed = apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
1✔
194
                        Type:               ConditionTypeTaskCompleted,
1✔
195
                        Status:             metav1.ConditionTrue,
1✔
196
                        ObservedGeneration: res.Generation,
1✔
197
                        Reason:             "TaskCompleted",
1✔
198
                        Message:            "RenderTask completed",
1✔
199
                })
1✔
200

1✔
201
                log.V(1).Info("RenderTask completed", "name", rt.Name)
1✔
202
                r.Recorder.Eventf(res, rt, corev1.EventTypeWarning, "TaskCompleted", "RunTask", "RenderTask completed successfully")
1✔
203

1✔
204
                return changed
1✔
205
        }
1✔
206

207
        log.V(1).Info("RenderTask has no final condtions yet", "name", rt.Name)
46✔
208

46✔
209
        return false
46✔
210
}
211

212
func (r *HydratedTargetReconciler) createRenderTask(ctx context.Context, res *solarv1alpha1.HydratedTarget) error {
30✔
213
        log := ctrl.LoggerFrom(ctx)
30✔
214

30✔
215
        // Check if we need to cleanup an old task
30✔
216
        if res.Status.RenderTaskRef != nil && res.Status.RenderTaskRef.Name != "" {
39✔
217
                if err := r.deleteRenderTask(ctx, res); err != nil {
9✔
218
                        return errLogAndWrap(log, err, "failed to cleanup old task")
×
219
                }
×
220
        }
221

222
        spec, err := r.computeRenderTaskSpec(ctx, res)
30✔
223
        if err != nil {
34✔
224
                return err
4✔
225
        }
4✔
226
        rt := &solarv1alpha1.RenderTask{
26✔
227
                ObjectMeta: metav1.ObjectMeta{
26✔
228
                        Name:      generationName(res),
26✔
229
                        Namespace: res.Namespace,
26✔
230
                },
26✔
231
                Spec: spec,
26✔
232
        }
26✔
233

26✔
234
        if err := r.Create(ctx, rt); err != nil {
27✔
235
                r.Recorder.Eventf(res, nil, corev1.EventTypeWarning, "CreationFailed", "Create", "Failed to create RenderTask", err)
1✔
236
                return errLogAndWrap(log, err, "secret creation failed")
1✔
237
        }
1✔
238

239
        // Set owner references
240
        if err := controllerutil.SetControllerReference(res, rt, r.Scheme); err != nil {
25✔
241
                return errLogAndWrap(log, err, "failed to set controller reference")
×
242
        }
×
243

244
        // Set Reference in Status
245
        res.Status.RenderTaskRef = &corev1.ObjectReference{
25✔
246
                APIVersion: solarv1alpha1.SchemeGroupVersion.String(),
25✔
247
                Kind:       "RenderTask",
25✔
248
                Namespace:  rt.Namespace,
25✔
249
                Name:       rt.Name,
25✔
250
        }
25✔
251

25✔
252
        if err := r.Status().Update(ctx, res); err != nil {
26✔
253
                return errLogAndWrap(log, err, "failed to update status")
1✔
254
        }
1✔
255

256
        return nil
24✔
257
}
258

259
func (r *HydratedTargetReconciler) deleteRenderTask(ctx context.Context, res *solarv1alpha1.HydratedTarget) error {
11✔
260
        if res.Status.RenderTaskRef == nil {
12✔
261
                return nil
1✔
262
        }
1✔
263

264
        rt := &solarv1alpha1.RenderTask{}
10✔
265
        if err := r.Get(ctx, client.ObjectKey{Name: res.Status.RenderTaskRef.Name, Namespace: res.Status.RenderTaskRef.Namespace}, rt); client.IgnoreNotFound(err) != nil {
10✔
266
                return err
×
267
        } else if err == nil {
20✔
268
                return r.Delete(ctx, rt, client.PropagationPolicy(metav1.DeletePropagationBackground))
10✔
269
        }
10✔
270

UNCOV
271
        return nil
×
272
}
273

274
func (r *HydratedTargetReconciler) computeRenderTaskSpec(ctx context.Context, res *solarv1alpha1.HydratedTarget) (solarv1alpha1.RenderTaskSpec, error) {
30✔
275
        spec := solarv1alpha1.RenderTaskSpec{}
30✔
276

30✔
277
        resolvedReleases := map[string]solarv1alpha1.ResourceAccess{}
30✔
278
        for k, v := range res.Spec.Releases {
43✔
279
                rel := &solarv1alpha1.Release{}
13✔
280
                if err := r.Get(ctx, client.ObjectKey{Name: v.Name, Namespace: res.Namespace}, rel); err != nil {
17✔
281
                        return spec, err
4✔
282
                }
4✔
283

284
                if rel.Status.ChartURL == "" {
9✔
285
                        return spec, fmt.Errorf("Release reference was empty, check if the release chart was rendered correctly.")
×
286
                }
×
287

288
                ref, err := ociname.ParseReference(rel.Status.ChartURL)
9✔
289
                if err != nil {
9✔
290
                        return spec, err
×
291
                }
×
292

293
                repo, err := url.JoinPath(ref.Context().RegistryStr(), ref.Context().RepositoryStr())
9✔
294
                if err != nil {
9✔
295
                        return spec, err
×
296
                }
×
297

298
                resolvedReleases[k] = solarv1alpha1.ResourceAccess{
9✔
299
                        Repository: strings.TrimPrefix(repo, "oci://"),
9✔
300
                        Tag:        ref.Identifier(),
9✔
301
                }
9✔
302
        }
303

304
        resolvedReleaseNames := []string{}
26✔
305
        for k := range resolvedReleases {
35✔
306
                resolvedReleaseNames = append(resolvedReleaseNames, k)
9✔
307
        }
9✔
308

309
        chartName := fmt.Sprintf("ht-%s", res.Name)
26✔
310
        repo, err := url.JoinPath(res.Namespace, chartName)
26✔
311
        if err != nil {
26✔
312
                return spec, err
×
313
        }
×
314

315
        tag := fmt.Sprintf("v0.0.%d", res.GetGeneration())
26✔
316

26✔
317
        spec.RendererConfig = solarv1alpha1.RendererConfig{
26✔
318
                Type: solarv1alpha1.RendererConfigTypeHydratedTarget,
26✔
319
                HydratedTargetConfig: solarv1alpha1.HydratedTargetConfig{
26✔
320
                        Chart: solarv1alpha1.ChartConfig{
26✔
321
                                Name:        chartName,
26✔
322
                                Description: fmt.Sprintf("HydratedTarget of %v", resolvedReleaseNames),
26✔
323
                                Version:     tag,
26✔
324
                                AppVersion:  tag,
26✔
325
                        },
26✔
326
                        Input: solarv1alpha1.HydratedTargetInput{
26✔
327
                                Releases: resolvedReleases,
26✔
328
                                Userdata: res.Spec.Userdata,
26✔
329
                        },
26✔
330
                },
26✔
331
        }
26✔
332
        spec.Repository = repo
26✔
333
        spec.Tag = tag
26✔
334

26✔
335
        return spec, nil
26✔
336
}
337

338
// SetupWithManager sets up the controller with the Manager.
339
func (r *HydratedTargetReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
340
        return ctrl.NewControllerManagedBy(mgr).
1✔
341
                For(&solarv1alpha1.HydratedTarget{}).
1✔
342
                Owns(&solarv1alpha1.RenderTask{}).
1✔
343
                Complete(r)
1✔
344
}
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