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

opendefensecloud / solution-arsenal / 21662714132

04 Feb 2026 07:35AM UTC coverage: 72.579% (+6.0%) from 66.532%
21662714132

Pull #92

github

web-flow
Merge 7c94065c6 into 25e2e0b69
Pull Request #92: Add controllers to schedule rendering jobs

482 of 612 new or added lines in 5 files covered. (78.76%)

44 existing lines in 3 files now uncovered.

982 of 1353 relevant lines covered (72.58%)

11.96 hits per line

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

70.59
/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
        "encoding/json"
9
        "fmt"
10
        "time"
11

12
        "github.com/go-logr/logr"
13
        solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1"
14
        "go.opendefense.cloud/solar/pkg/renderer"
15
        batchv1 "k8s.io/api/batch/v1"
16
        corev1 "k8s.io/api/core/v1"
17
        apierrors "k8s.io/apimachinery/pkg/api/errors"
18
        apimeta "k8s.io/apimachinery/pkg/api/meta"
19
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20
        "k8s.io/apimachinery/pkg/runtime"
21
        "k8s.io/client-go/tools/record"
22
        ctrl "sigs.k8s.io/controller-runtime"
23
        "sigs.k8s.io/controller-runtime/pkg/client"
24
)
25

26
// HydratedTargetReconciler reconciles a HydratedTarget object
27
type HydratedTargetReconciler struct {
28
        client.Client
29
        Scheme          *runtime.Scheme
30
        Recorder        record.EventRecorder
31
        RendererImage   string
32
        RendererCommand string
33
        RendererArgs    []string
34
        PushOptions     renderer.PushOptions
35
}
36

37
// Ensure HydratedTargetReconciler implements ConfigBuilder
38
var _ ConfigBuilder = (*HydratedTargetReconciler)(nil)
39

40
// Implement ConfigBuilder interface
41
func (r *HydratedTargetReconciler) BuildConfig(ctx context.Context, log logr.Logger, obj RenderJobObject) ([]byte, error) {
8✔
42
        adapter := obj.(*hydratedTargetAdapter)
8✔
43
        ht := adapter.HydratedTarget
8✔
44
        _ = ht
8✔
45

8✔
46
        // Build the renderer configuration
8✔
47
        cfg := renderer.Config{ // TODO:
8✔
48
                Type:                 renderer.TypeHydratedTarget,
8✔
49
                HydratedTargetConfig: renderer.HydratedTargetConfig{}, // TODO: fill in HydratedTargetConfig
8✔
50
                PushOptions:          r.PushOptions,
8✔
51
        }
8✔
52

8✔
53
        return json.Marshal(cfg)
8✔
54
}
8✔
55

56
func (r *HydratedTargetReconciler) GetRecorder() record.EventRecorder {
14✔
57
        return r.Recorder
14✔
58
}
14✔
59

60
func (r *HydratedTargetReconciler) GetRendererImage() string {
8✔
61
        return r.RendererImage
8✔
62
}
8✔
63

64
func (r *HydratedTargetReconciler) GetRendererCommand() string {
8✔
65
        return r.RendererCommand
8✔
66
}
8✔
67

68
func (r *HydratedTargetReconciler) GetRendererArgs() []string {
8✔
69
        return r.RendererArgs
8✔
70
}
8✔
71

72
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets,verbs=get;list;watch;create;update;patch;delete
73
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets/status,verbs=get;update;patch
74
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=hydratedtargets/finalizers,verbs=update
75
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
76
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
77
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
78

79
// Reconcile moves the current state of the cluster closer to the desired state
80
//
81
// Reconciliation Flow:
82
//
83
//        HydratedTarget created
84
//            ↓
85
//        Add finalizer
86
//            ↓
87
//        Check if already succeeded → YES → Return (no-op)
88
//            ↓ NO
89
//        Create/update config secret
90
//            ↓
91
//        Get or create job
92
//            ↓
93
//        Update release status from job
94
//            ↓
95
//        Job completed with success?
96
//            ├→ YES → Cleanup resources → Return
97
//            └→ NO → Still running? → Requeue in 5s
98
func (r *HydratedTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
51✔
99
        log := ctrl.LoggerFrom(ctx)
51✔
100
        ctrlResult := ctrl.Result{}
51✔
101

51✔
102
        log.V(1).Info("HydratedTarget is being reconciled", "req", req)
51✔
103

51✔
104
        // Fetch the HydratedTarget instance
51✔
105
        ht := &solarv1alpha1.HydratedTarget{}
51✔
106
        if err := r.Get(ctx, req.NamespacedName, ht); err != nil {
53✔
107
                if apierrors.IsNotFound(err) {
4✔
108
                        // Object not found, return. Created objects are automatically garbage collected.
2✔
109
                        return ctrlResult, nil
2✔
110
                }
2✔
NEW
111
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
112
        }
113

114
        // Create helper with HydratedTarget-specific config builder
115
        helper := &RenderJobHelper{
49✔
116
                Client:        r.Client,
49✔
117
                Scheme:        r.Scheme,
49✔
118
                ConfigBuilder: r,
49✔
119
        }
49✔
120

49✔
121
        // Create an adapter that wraps HydratedTarget as RenderJobObject
49✔
122
        adapter := &hydratedTargetAdapter{HydratedTarget: ht}
49✔
123

49✔
124
        // Handle deletion: cleanup job and secret, then remove finalizer
49✔
125
        if !ht.DeletionTimestamp.IsZero() {
50✔
126
                log.V(1).Info("HydratedTarget is being deleted")
1✔
127
                r.Recorder.Event(ht, corev1.EventTypeWarning, "Deleting", "HydratedTarget is being deleted, cleaning up resources")
1✔
128

1✔
129
                if err := helper.CleanupResources(ctx, log, adapter); err != nil {
1✔
NEW
130
                        return ctrlResult, errLogAndWrap(log, err, "failed to cleanup resources")
×
NEW
131
                }
×
132

133
                if err := helper.RemoveFinalizer(ctx, log, adapter, ht); err != nil {
1✔
NEW
134
                        return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer")
×
NEW
135
                }
×
136
                return ctrlResult, nil
1✔
137
        }
138

139
        // Add finalizer if not present
140
        added, err := helper.EnsureFinalizer(ctx, log, adapter, ht)
48✔
141
        if err != nil {
48✔
NEW
142
                return ctrlResult, errLogAndWrap(log, err, "failed to ensure finalizer")
×
NEW
143
        }
×
144
        if added {
56✔
145
                // Return without requeue; the Update event will trigger reconciliation again
8✔
146
                return ctrlResult, nil
8✔
147
        }
8✔
148

149
        // Check if hydrated target has already completed successfully
150
        if apimeta.IsStatusConditionTrue(ht.Status.Conditions, ConditionTypeJobSucceeded) {
45✔
151
                log.V(1).Info("HydratedTarget has already completed successfully, no further action needed")
5✔
152
                return ctrlResult, nil
5✔
153
        }
5✔
154

155
        // Create or update configuration secret
156
        configSecret, err := helper.CreateOrUpdateConfigSecret(ctx, log, adapter)
35✔
157
        if err != nil {
35✔
NEW
158
                r.Recorder.Event(ht, corev1.EventTypeWarning, "ConfigFailed", fmt.Sprintf("Failed to create config secret: %v", err))
×
NEW
159
                if changed := apimeta.SetStatusCondition(&ht.Status.Conditions, metav1.Condition{
×
NEW
160
                        Type:               ConditionTypeJobScheduled,
×
NEW
161
                        Status:             metav1.ConditionFalse,
×
NEW
162
                        ObservedGeneration: ht.Generation,
×
NEW
163
                        Reason:             "ConfigSecretFailed",
×
NEW
164
                        Message:            fmt.Sprintf("Failed to create config secret: %v", err),
×
NEW
165
                }); changed {
×
NEW
166
                        if err := r.Status().Update(ctx, ht); err != nil {
×
NEW
167
                                log.Error(err, "failed to update HydratedTarget status")
×
NEW
168
                        }
×
169
                }
NEW
170
                return ctrlResult, errLogAndWrap(log, err, "failed to create config secret")
×
171
        }
172

173
        ht.Status.ConfigSecretRef = &corev1.ObjectReference{
35✔
174
                APIVersion: "v1",
35✔
175
                Kind:       "Secret",
35✔
176
                Name:       configSecret.Name,
35✔
177
                Namespace:  configSecret.Namespace,
35✔
178
                UID:        configSecret.UID,
35✔
179
        }
35✔
180

35✔
181
        // Get or create the job
35✔
182
        job, err := helper.GetOrCreateJob(ctx, log, adapter, configSecret)
35✔
183
        if err != nil {
35✔
NEW
184
                r.Recorder.Event(ht, corev1.EventTypeWarning, "JobFailed", fmt.Sprintf("Failed to create or get job: %v", err))
×
NEW
185
                if changed := apimeta.SetStatusCondition(&ht.Status.Conditions, metav1.Condition{
×
NEW
186
                        Type:               ConditionTypeJobScheduled,
×
NEW
187
                        Status:             metav1.ConditionFalse,
×
NEW
188
                        ObservedGeneration: ht.Generation,
×
NEW
189
                        Reason:             "JobCreationFailed",
×
NEW
190
                        Message:            fmt.Sprintf("Failed to create job: %v", err),
×
NEW
191
                }); changed {
×
NEW
192
                        if err := r.Status().Update(ctx, ht); err != nil {
×
NEW
193
                                log.Error(err, "failed to update HydratedTarget status")
×
NEW
194
                        }
×
195
                }
NEW
196
                return ctrlResult, errLogAndWrap(log, err, "failed to create job")
×
197
        }
198

199
        if job != nil {
70✔
200
                ht.Status.JobRef = &corev1.ObjectReference{
35✔
201
                        APIVersion: "batch/v1",
35✔
202
                        Kind:       "Job",
35✔
203
                        Name:       job.Name,
35✔
204
                        Namespace:  job.Namespace,
35✔
205
                        UID:        job.UID,
35✔
206
                }
35✔
207

35✔
208
                // Check job status and update status if required
35✔
209
                if changed := helper.UpdateResourceStatusFromJob(ctx, log, adapter, job); changed {
47✔
210
                        if err := r.Status().Update(ctx, ht); err != nil {
13✔
211
                                return ctrlResult, errLogAndWrap(log, err, "failed to update HydratedTarget status")
1✔
212
                        }
1✔
213
                }
214

215
                // Check if job completed successfully
216
                if IsJobComplete(job) && job.Status.Succeeded > 0 {
36✔
217
                        log.V(1).Info("Job completed successfully, cleaning up job and secret")
2✔
218
                        if err := helper.CleanupResources(ctx, log, adapter); err != nil {
2✔
NEW
219
                                log.Error(err, "failed to cleanup resources after successful job completion")
×
NEW
220
                                // Don't fail reconciliation, job is already successful
×
NEW
221
                        }
×
222
                        return ctrlResult, nil
2✔
223
                }
224

225
                // Check if job is still running
226
                if !IsJobComplete(job) {
64✔
227
                        log.V(1).Info("Job is still running, requeue after 5 seconds")
32✔
228
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
32✔
229
                }
32✔
230
        }
231

NEW
232
        return ctrlResult, nil
×
233
}
234

235
// SetupWithManager sets up the controller with the Manager.
236
func (r *HydratedTargetReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
237
        return ctrl.NewControllerManagedBy(mgr).
1✔
238
                For(&solarv1alpha1.HydratedTarget{}).
1✔
239
                Owns(&batchv1.Job{}).
1✔
240
                Owns(&corev1.Secret{}).
1✔
241
                Complete(r)
1✔
242
}
1✔
243

244
// Adapter to use HydratedTarget directly as RenderJobObject while delegating to client.Update
245
type hydratedTargetAdapter struct {
246
        *solarv1alpha1.HydratedTarget
247
}
248

249
func (a *hydratedTargetAdapter) GetConditions() []metav1.Condition {
35✔
250
        return a.Status.Conditions
35✔
251
}
35✔
252

253
func (a *hydratedTargetAdapter) SetConditions(conditions []metav1.Condition) {
35✔
254
        a.Status.Conditions = conditions
35✔
255
}
35✔
256

257
func (a *hydratedTargetAdapter) GetResourceName() string {
76✔
258
        return a.Name
76✔
259
}
76✔
260

NEW
261
func (a *hydratedTargetAdapter) GetObjectReference() *corev1.ObjectReference {
×
NEW
262
        return &corev1.ObjectReference{
×
NEW
263
                APIVersion: solarv1alpha1.SchemeGroupVersion.String(),
×
NEW
264
                Kind:       "HydratedTarget",
×
NEW
265
                Name:       a.Name,
×
NEW
266
                Namespace:  a.Namespace,
×
NEW
267
                UID:        a.UID,
×
NEW
268
        }
×
NEW
269
}
×
270

NEW
271
func (a *hydratedTargetAdapter) SetJobRef(ref *corev1.ObjectReference) {
×
NEW
272
        a.Status.JobRef = ref
×
NEW
273
}
×
274

NEW
275
func (a *hydratedTargetAdapter) SetConfigSecretRef(ref *corev1.ObjectReference) {
×
NEW
276
        a.Status.ConfigSecretRef = ref
×
NEW
277
}
×
278

279
func (a *hydratedTargetAdapter) RuntimeObject() runtime.Object {
14✔
280
        return a.HydratedTarget
14✔
281
}
14✔
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