• 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

72.83
/pkg/controller/release_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
// ReleaseReconciler reconciles a Release object
27
type ReleaseReconciler 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 ReleaseReconciler implements ConfigBuilder
38
var _ ConfigBuilder = (*ReleaseReconciler)(nil)
39

40
// Implement ConfigBuilder interface
41
func (r *ReleaseReconciler) BuildConfig(ctx context.Context, log logr.Logger, obj RenderJobObject) ([]byte, error) {
8✔
42
        adapter := obj.(*releaseAdapter)
8✔
43
        rel := adapter.Release
8✔
44

8✔
45
        // Build the renderer configuration
8✔
46
        cfg := renderer.Config{ // TODO
8✔
47
                Type: renderer.TypeRelease,
8✔
48
                ReleaseConfig: renderer.ReleaseConfig{
8✔
49
                        Chart: renderer.ChartConfig{
8✔
50
                                Name:        rel.Name,
8✔
51
                                Description: fmt.Sprintf("Release of %s", rel.Spec.ComponentVersionRef.Name),
8✔
52
                                Version:     "1.0.0", // TODO: derive from component version
8✔
53
                                AppVersion:  "1.0.0", // TODO: derive from component version
8✔
54
                        },
8✔
55
                        Input: renderer.ReleaseInput{
8✔
56
                                Component: renderer.ReleaseComponent{}, // TODO: populate from component version
8✔
57
                                Helm:      renderer.ResourceAccess{},   // TODO: populate from component version
8✔
58
                                KRO:       renderer.ResourceAccess{},   // TODO: populate from component version
8✔
59
                                Resources: make(map[string]renderer.ResourceAccess),
8✔
60
                        },
8✔
61
                        Values: rel.Spec.Values.Raw,
8✔
62
                },
8✔
63
                PushOptions: r.PushOptions,
8✔
64
        }
8✔
65

8✔
66
        // Marshal config to JSON
8✔
67
        return json.Marshal(cfg)
8✔
68
}
8✔
69

70
func (r *ReleaseReconciler) GetRecorder() record.EventRecorder {
12✔
71
        return r.Recorder
12✔
72
}
12✔
73

74
func (r *ReleaseReconciler) GetRendererImage() string {
8✔
75
        return r.RendererImage
8✔
76
}
8✔
77

78
func (r *ReleaseReconciler) GetRendererCommand() string {
8✔
79
        return r.RendererCommand
8✔
80
}
8✔
81

82
func (r *ReleaseReconciler) GetRendererArgs() []string {
8✔
83
        return r.RendererArgs
8✔
84
}
8✔
85

86
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases,verbs=get;list;watch;create;update;patch;delete
87
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases/status,verbs=get;update;patch
88
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases/finalizers,verbs=update
89
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
90
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
91
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
92

93
// Reconcile moves the current state of the cluster closer to the desired state
94
//
95
// Reconciliation Flow:
96
//
97
//        Release created
98
//            ↓
99
//        Add finalizer
100
//            ↓
101
//        Check if already succeeded → YES → Return (no-op)
102
//            ↓ NO
103
//        Create/update config secret
104
//            ↓
105
//        Get or create job
106
//            ↓
107
//        Update release status from job
108
//            ↓
109
//        Job completed with success?
110
//            ├→ YES → Cleanup resources → Return
111
//            └→ NO → Still running? → Requeue in 5s
112
func (r *ReleaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
43✔
113
        log := ctrl.LoggerFrom(ctx)
43✔
114
        ctrlResult := ctrl.Result{}
43✔
115

43✔
116
        log.V(1).Info("Release is being reconciled", "req", req)
43✔
117

43✔
118
        // Fetch the Release instance
43✔
119
        res := &solarv1alpha1.Release{}
43✔
120
        if err := r.Get(ctx, req.NamespacedName, res); err != nil {
45✔
121
                if apierrors.IsNotFound(err) {
4✔
122
                        // Object not found, return. Created objects are automatically garbage collected.
2✔
123
                        return ctrlResult, nil
2✔
124
                }
2✔
NEW
125
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
126
        }
127

128
        // Create helper with Release-specific config builder
129
        helper := &RenderJobHelper{
41✔
130
                Client:        r.Client,
41✔
131
                Scheme:        r.Scheme,
41✔
132
                ConfigBuilder: r,
41✔
133
        }
41✔
134

41✔
135
        // Create an adapter that wraps Release as RenderJobObject
41✔
136
        adapter := &releaseAdapter{Release: res}
41✔
137

41✔
138
        // Handle deletion: cleanup job and secret, then remove finalizer
41✔
139
        if !res.DeletionTimestamp.IsZero() {
43✔
140
                log.V(1).Info("Release is being deleted")
2✔
141
                r.Recorder.Event(res, corev1.EventTypeWarning, "Deleting", "Release is being deleted, cleaning up resources")
2✔
142

2✔
143
                if err := helper.CleanupResources(ctx, log, adapter); err != nil {
2✔
NEW
144
                        return ctrlResult, errLogAndWrap(log, err, "failed to cleanup resources")
×
NEW
145
                }
×
146

147
                if err := helper.RemoveFinalizer(ctx, log, adapter, res); err != nil {
3✔
148
                        return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer")
1✔
149
                }
1✔
150
                return ctrlResult, nil
1✔
151
        }
152

153
        // Add finalizer if not present
154
        added, err := helper.EnsureFinalizer(ctx, log, adapter, res)
39✔
155
        if err != nil {
39✔
NEW
156
                return ctrlResult, errLogAndWrap(log, err, "failed to ensure finalizer")
×
NEW
157
        }
×
158
        if added {
47✔
159
                // Return without requeue; the Update event will trigger reconciliation again
8✔
160
                return ctrlResult, nil
8✔
161
        }
8✔
162

163
        // Check if release has already completed successfully
164
        if apimeta.IsStatusConditionTrue(res.Status.Conditions, ConditionTypeJobSucceeded) {
36✔
165
                log.V(1).Info("Release has already completed successfully, no further action needed")
5✔
166
                return ctrlResult, nil
5✔
167
        }
5✔
168

169
        // Create or update configuration secret
170
        configSecret, err := helper.CreateOrUpdateConfigSecret(ctx, log, adapter)
26✔
171
        if err != nil {
26✔
NEW
172
                r.Recorder.Event(res, corev1.EventTypeWarning, "ConfigFailed", fmt.Sprintf("Failed to create config secret: %v", err))
×
NEW
173
                if changed := apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
×
NEW
174
                        Type:               ConditionTypeJobScheduled,
×
NEW
175
                        Status:             metav1.ConditionFalse,
×
NEW
176
                        ObservedGeneration: res.Generation,
×
NEW
177
                        Reason:             "ConfigSecretFailed",
×
NEW
178
                        Message:            fmt.Sprintf("Failed to create config secret: %v", err),
×
NEW
179
                }); changed {
×
NEW
180
                        if err := r.Status().Update(ctx, res); err != nil {
×
NEW
181
                                log.Error(err, "failed to update Release status")
×
NEW
182
                        }
×
183
                }
NEW
184
                return ctrlResult, errLogAndWrap(log, err, "failed to create config secret")
×
185
        }
186

187
        res.Status.ConfigSecretRef = &corev1.ObjectReference{
26✔
188
                APIVersion: "v1",
26✔
189
                Kind:       "Secret",
26✔
190
                Name:       configSecret.Name,
26✔
191
                Namespace:  configSecret.Namespace,
26✔
192
                UID:        configSecret.UID,
26✔
193
        }
26✔
194

26✔
195
        // Get or create the job
26✔
196
        job, err := helper.GetOrCreateJob(ctx, log, adapter, configSecret)
26✔
197
        if err != nil {
26✔
NEW
198
                r.Recorder.Event(res, corev1.EventTypeWarning, "JobFailed", fmt.Sprintf("Failed to create or get job: %v", err))
×
NEW
199
                if changed := apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
×
NEW
200
                        Type:               ConditionTypeJobScheduled,
×
NEW
201
                        Status:             metav1.ConditionFalse,
×
NEW
202
                        ObservedGeneration: res.Generation,
×
NEW
203
                        Reason:             "JobCreationFailed",
×
NEW
204
                        Message:            fmt.Sprintf("Failed to create job: %v", err),
×
NEW
205
                }); changed {
×
NEW
206
                        if err := r.Status().Update(ctx, res); err != nil {
×
NEW
207
                                log.Error(err, "failed to update Release status")
×
NEW
208
                        }
×
209
                }
NEW
210
                return ctrlResult, errLogAndWrap(log, err, "failed to create job")
×
211
        }
212

213
        if job != nil {
52✔
214
                res.Status.JobRef = &corev1.ObjectReference{
26✔
215
                        APIVersion: "batch/v1",
26✔
216
                        Kind:       "Job",
26✔
217
                        Name:       job.Name,
26✔
218
                        Namespace:  job.Namespace,
26✔
219
                        UID:        job.UID,
26✔
220
                }
26✔
221

26✔
222
                // Check job status and update status if required
26✔
223
                if changed := helper.UpdateResourceStatusFromJob(ctx, log, adapter, job); changed {
37✔
224
                        if err := r.Status().Update(ctx, res); err != nil {
11✔
NEW
225
                                return ctrlResult, errLogAndWrap(log, err, "failed to update Release status")
×
NEW
226
                        }
×
227
                }
228

229
                // Check if job completed successfully
230
                if IsJobComplete(job) && job.Status.Succeeded > 0 {
28✔
231
                        log.V(1).Info("Job completed successfully, cleaning up job and secret")
2✔
232
                        if err := helper.CleanupResources(ctx, log, adapter); err != nil {
2✔
NEW
233
                                log.Error(err, "failed to cleanup resources after successful job completion")
×
NEW
234
                                // Don't fail reconciliation, job is already successful
×
NEW
235
                        }
×
236
                        return ctrlResult, nil
2✔
237
                }
238

239
                // Check if job is still running
240
                if !IsJobComplete(job) {
48✔
241
                        log.V(1).Info("Job is still running, requeue after 5 seconds")
24✔
242
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
24✔
243
                }
24✔
244
        }
245

NEW
246
        return ctrlResult, nil
×
247
}
248

249
// SetupWithManager sets up the controller with the Manager.
250
func (r *ReleaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
251
        return ctrl.NewControllerManagedBy(mgr).
1✔
252
                For(&solarv1alpha1.Release{}).
1✔
253
                Owns(&batchv1.Job{}).
1✔
254
                Owns(&corev1.Secret{}).
1✔
255
                Complete(r)
1✔
256
}
1✔
257

258
// Adapter to use Release directly as RenderJobObject while delegating to client.Update
259
type releaseAdapter struct {
260
        *solarv1alpha1.Release
261
}
262

263
func (a *releaseAdapter) GetConditions() []metav1.Condition {
26✔
264
        return a.Status.Conditions
26✔
265
}
26✔
266

267
func (a *releaseAdapter) SetConditions(conditions []metav1.Condition) {
26✔
268
        a.Status.Conditions = conditions
26✔
269
}
26✔
270

271
func (a *releaseAdapter) GetResourceName() string {
60✔
272
        return a.Name
60✔
273
}
60✔
274

NEW
275
func (a *releaseAdapter) GetObjectReference() *corev1.ObjectReference {
×
NEW
276
        return &corev1.ObjectReference{
×
NEW
277
                APIVersion: solarv1alpha1.SchemeGroupVersion.String(),
×
NEW
278
                Kind:       "Release",
×
NEW
279
                Name:       a.Name,
×
NEW
280
                Namespace:  a.Namespace,
×
NEW
281
                UID:        a.UID,
×
NEW
282
        }
×
NEW
283
}
×
284

NEW
285
func (a *releaseAdapter) SetJobRef(ref *corev1.ObjectReference) {
×
NEW
286
        a.Status.JobRef = ref
×
NEW
287
}
×
288

NEW
289
func (a *releaseAdapter) SetConfigSecretRef(ref *corev1.ObjectReference) {
×
NEW
290
        a.Status.ConfigSecretRef = ref
×
NEW
291
}
×
292

293
func (a *releaseAdapter) RuntimeObject() runtime.Object {
12✔
294
        return a.Release
12✔
295
}
12✔
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