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

opendefensecloud / solution-arsenal / 26530885563

27 May 2026 06:32PM UTC coverage: 71.664%. First build
26530885563

Pull #554

github

web-flow
Merge ba638e819 into ce1ad469b
Pull Request #554: feat: implemented artifact cleanup

263 of 363 new or added lines in 6 files covered. (72.45%)

2610 of 3642 relevant lines covered (71.66%)

24.17 hits per line

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

72.22
/pkg/controller/renderartifact_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
        "slices"
10

11
        "github.com/google/go-containerregistry/pkg/authn"
12
        corev1 "k8s.io/api/core/v1"
13
        apierrors "k8s.io/apimachinery/pkg/api/errors"
14
        apimeta "k8s.io/apimachinery/pkg/api/meta"
15
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
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/client"
21
        "sigs.k8s.io/controller-runtime/pkg/handler"
22
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
23

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

28
const (
29
        renderArtifactFinalizer = "solar.opendefense.cloud/render-artifact-finalizer"
30
        ConditionTypeOCICleanup = "OCICleanup"
31
)
32

33
// RenderArtifactReconciler reconciles RenderArtifact objects.
34
// It sets status.ChartURL and acts as the GC controller: when the last RenderBinding
35
// referencing a RenderArtifact is removed, it attempts to delete the OCI tag
36
// and then deletes the RenderArtifact object itself.
37
//
38
// OCI tag deletion failures are surfaced as a status condition and a Warning event
39
// so users have visibility; the finalizer is kept until the deletion succeeds,
40
// making the artifact object "stuck" in a visible state.
41
type RenderArtifactReconciler struct {
42
        client.Client
43
        Scheme   *runtime.Scheme
44
        Recorder events.EventRecorder
45
        // DeleteTag overrides the OCI tag deletion function used during GC.
46
        // Defaults to ociregistry.DeleteTag; replaced in tests.
47
        DeleteTag func(ctx context.Context, rawRef string, auth authn.Authenticator) error
48
        // WatchNamespace restricts reconciliation to this namespace.
49
        // Should be empty in production (watches all namespaces).
50
        // Intended for use in integration tests only.
51
        WatchNamespace string
52
}
53

54
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts,verbs=get;list;watch;update;patch;delete
55
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts/status,verbs=get;update;patch
56
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts/finalizers,verbs=update
57
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderbindings,verbs=get;list;watch
58
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get
59

60
func (r *RenderArtifactReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
81✔
61
        log := ctrl.LoggerFrom(ctx)
81✔
62

81✔
63
        log.V(1).Info("RenderArtifact is being reconciled", "req", req)
81✔
64

81✔
65
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
102✔
66
                return ctrl.Result{}, nil
21✔
67
        }
21✔
68

69
        artifact := &solarv1alpha1.RenderArtifact{}
60✔
70
        if err := r.Get(ctx, req.NamespacedName, artifact); err != nil {
66✔
71
                if apierrors.IsNotFound(err) {
12✔
72
                        return ctrl.Result{}, nil
6✔
73
                }
6✔
74

NEW
75
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to get RenderArtifact")
×
76
        }
77

78
        // Handle deletion: attempt OCI tag cleanup, surface errors explicitly, then remove finalizer.
79
        if !artifact.DeletionTimestamp.IsZero() {
67✔
80
                if slices.Contains(artifact.Finalizers, renderArtifactFinalizer) {
26✔
81
                        if err := r.cleanupOCIArtifact(ctx, artifact); err != nil {
20✔
82
                                // Failure is already logged + event fired inside cleanupOCIArtifact.
7✔
83
                                // Keep the finalizer by returning the error so the object stays visible
7✔
84
                                // with the OCICleanup=False condition set.
7✔
85
                                return ctrl.Result{}, err
7✔
86
                        }
7✔
87

88
                        // OCI cleanup succeeded — remove finalizer to allow K8s deletion.
89
                        latest := artifact.DeepCopy()
6✔
90
                        latest.Finalizers = slices.DeleteFunc(latest.Finalizers, func(s string) bool {
12✔
91
                                return s == renderArtifactFinalizer
6✔
92
                        })
6✔
93
                        if err := r.Patch(ctx, latest, client.MergeFrom(artifact)); err != nil {
6✔
NEW
94
                                return ctrl.Result{}, errLogAndWrap(log, err, "failed to remove finalizer from RenderArtifact")
×
NEW
95
                        }
×
96
                }
97

98
                return ctrl.Result{}, nil
6✔
99
        }
100

101
        // Ensure finalizer is set.
102
        if !slices.Contains(artifact.Finalizers, renderArtifactFinalizer) {
54✔
103
                latest := artifact.DeepCopy()
13✔
104
                latest.Finalizers = append(latest.Finalizers, renderArtifactFinalizer)
13✔
105
                if err := r.Patch(ctx, latest, client.MergeFrom(artifact)); err != nil {
13✔
NEW
106
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to add finalizer to RenderArtifact")
×
NEW
107
                }
×
108

109
                return ctrl.Result{}, nil
13✔
110
        }
111

112
        // Populate status.ChartURL from spec coordinates if not yet set.
113
        chartURL := renderChartURL(artifact.Spec.BaseURL, artifact.Spec.Repository, artifact.Spec.Tag)
28✔
114
        if artifact.Status.ChartURL != chartURL {
41✔
115
                artifact.Status.ChartURL = chartURL
13✔
116
                if err := r.Status().Update(ctx, artifact); err != nil {
13✔
NEW
117
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to update RenderArtifact status")
×
NEW
118
                }
×
119
        }
120

121
        // List RenderBindings referencing this artifact.
122
        bindingList := &solarv1alpha1.RenderBindingList{}
28✔
123
        if err := r.List(ctx, bindingList,
28✔
124
                client.InNamespace(artifact.Namespace),
28✔
125
                client.MatchingFields{indexRenderBindingArtifactName: artifact.Name},
28✔
126
        ); err != nil {
28✔
NEW
127
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to list RenderBindings for RenderArtifact")
×
NEW
128
        }
×
129

130
        // If no bindings remain, trigger GC by deleting this object.
131
        // The finalizer above will intercept the deletion and handle OCI cleanup.
132
        if len(bindingList.Items) == 0 {
34✔
133
                log.V(1).Info("No RenderBindings remain for RenderArtifact — triggering GC",
6✔
134
                        "artifact", artifact.Name)
6✔
135
                if err := r.Delete(ctx, artifact); client.IgnoreNotFound(err) != nil {
6✔
NEW
136
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to delete orphaned RenderArtifact")
×
NEW
137
                }
×
138
        }
139

140
        return ctrl.Result{}, nil
28✔
141
}
142

143
// cleanupOCIArtifact attempts to delete the OCI tag from the registry.
144
// On failure it sets a status condition and fires a Warning event so the user
145
// can see why the RenderArtifact is stuck, then returns the error to keep the
146
// finalizer in place.
147
func (r *RenderArtifactReconciler) cleanupOCIArtifact(ctx context.Context, artifact *solarv1alpha1.RenderArtifact) error {
13✔
148
        log := ctrl.LoggerFrom(ctx)
13✔
149

13✔
150
        rawRef := artifact.Spec.BaseURL + "/" + artifact.Spec.Repository + ":" + artifact.Spec.Tag
13✔
151
        log.V(1).Info("Attempting OCI tag cleanup", "ref", rawRef)
13✔
152

13✔
153
        deleteFn := r.DeleteTag
13✔
154
        if deleteFn == nil {
13✔
NEW
155
                deleteFn = ociregistry.DeleteTag
×
NEW
156
        }
×
157

158
        auth := r.resolveAuth(ctx, artifact)
13✔
159

13✔
160
        if err := deleteFn(ctx, rawRef, auth); err != nil {
20✔
161
                log.Error(err, "Failed to delete OCI tag; RenderArtifact will remain until deletion succeeds",
7✔
162
                        "ref", rawRef, "artifact", artifact.Name)
7✔
163
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
7✔
164
                        "OCICleanupFailed", "Delete",
7✔
165
                        "Failed to delete OCI tag %s: %s", rawRef, err.Error())
7✔
166

7✔
167
                latest := artifact.DeepCopy()
7✔
168
                apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{
7✔
169
                        Type:               ConditionTypeOCICleanup,
7✔
170
                        Status:             metav1.ConditionFalse,
7✔
171
                        ObservedGeneration: artifact.Generation,
7✔
172
                        Reason:             "DeleteFailed",
7✔
173
                        Message:            err.Error(),
7✔
174
                })
7✔
175
                // Status update, if it fails, the event + log are visible in kubectl
7✔
176
                if sErr := r.Status().Update(ctx, latest); sErr != nil {
7✔
NEW
177
                        log.Error(sErr, "failed to update status condition after OCI cleanup failure")
×
NEW
178
                }
×
179

180
                return err
7✔
181
        }
182

183
        log.V(1).Info("OCI tag deleted successfully", "ref", rawRef)
6✔
184
        r.Recorder.Eventf(artifact, nil, corev1.EventTypeNormal,
6✔
185
                "OCICleanupSucceeded", "Delete",
6✔
186
                "Successfully deleted OCI tag %s", rawRef)
6✔
187

6✔
188
        return nil
6✔
189
}
190

191
// resolveAuth builds an authn.Authenticator from the artifact's PushSecretRef.
192
// Returns authn.Anonymous if no secret is configured or if loading fails.
193
func (r *RenderArtifactReconciler) resolveAuth(ctx context.Context, artifact *solarv1alpha1.RenderArtifact) authn.Authenticator {
13✔
194
        log := ctrl.LoggerFrom(ctx)
13✔
195

13✔
196
        if artifact.Spec.PushSecretRef == nil {
24✔
197
                return authn.Anonymous
11✔
198
        }
11✔
199

200
        secret := &corev1.Secret{}
2✔
201
        if err := r.Get(ctx, client.ObjectKey{
2✔
202
                Name:      artifact.Spec.PushSecretRef.Name,
2✔
203
                Namespace: artifact.Namespace,
2✔
204
        }, secret); err != nil {
4✔
205
                log.Error(err, "Failed to get push secret for OCI auth; proceeding anonymously",
2✔
206
                        "secret", artifact.Spec.PushSecretRef.Name)
2✔
207

2✔
208
                return authn.Anonymous
2✔
209
        }
2✔
210

NEW
211
        return ociAuthFromSecret(secret, artifact.Spec.BaseURL)
×
212
}
213

NEW
214
func ociAuthFromSecret(secret *corev1.Secret, registryHost string) authn.Authenticator {
×
NEW
215
        if secret.Type == corev1.SecretTypeBasicAuth {
×
NEW
216
                user := string(secret.Data["username"])
×
NEW
217
                pass := string(secret.Data["password"])
×
NEW
218
                if user != "" || pass != "" {
×
NEW
219
                        return authn.FromConfig(authn.AuthConfig{Username: user, Password: pass})
×
NEW
220
                }
×
221

NEW
222
                return authn.Anonymous
×
223
        }
224

NEW
225
        data := secret.Data[corev1.DockerConfigJsonKey]
×
NEW
226
        if len(data) == 0 {
×
NEW
227
                return authn.Anonymous
×
NEW
228
        }
×
229

NEW
230
        var cfg struct {
×
NEW
231
                Auths map[string]authn.AuthConfig `json:"auths"`
×
NEW
232
        }
×
NEW
233
        if err := json.Unmarshal(data, &cfg); err != nil {
×
NEW
234
                return authn.Anonymous
×
NEW
235
        }
×
236

NEW
237
        if ac, ok := cfg.Auths[registryHost]; ok {
×
NEW
238
                return authn.FromConfig(ac)
×
NEW
239
        }
×
240

NEW
241
        if ac, ok := cfg.Auths["https://"+registryHost]; ok {
×
NEW
242
                return authn.FromConfig(ac)
×
NEW
243
        }
×
244

NEW
245
        return authn.Anonymous
×
246
}
247

248
// mapRenderBindingToArtifact maps a RenderBinding event to a reconcile request
249
// for the RenderArtifact it references, so the GC controller is triggered on
250
// every RenderBinding deletion.
251
func mapRenderBindingToArtifact(_ context.Context, obj client.Object) []reconcile.Request {
22✔
252
        rb, ok := obj.(*solarv1alpha1.RenderBinding)
22✔
253
        if !ok {
22✔
NEW
254
                return nil
×
NEW
255
        }
×
256

257
        if rb.Spec.RenderArtifactRef.Name == "" {
22✔
NEW
258
                return nil
×
NEW
259
        }
×
260

261
        return []reconcile.Request{
22✔
262
                {
22✔
263
                        NamespacedName: types.NamespacedName{
22✔
264
                                Name:      rb.Spec.RenderArtifactRef.Name,
22✔
265
                                Namespace: rb.Namespace,
22✔
266
                        },
22✔
267
                },
22✔
268
        }
22✔
269
}
270

271
// SetupWithManager sets up the controller with the Manager.
272
func (r *RenderArtifactReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
273
        return ctrl.NewControllerManagedBy(mgr).
1✔
274
                For(&solarv1alpha1.RenderArtifact{}).
1✔
275
                Watches(
1✔
276
                        &solarv1alpha1.RenderBinding{},
1✔
277
                        handler.EnqueueRequestsFromMapFunc(mapRenderBindingToArtifact),
1✔
278
                ).
1✔
279
                Complete(r)
1✔
280
}
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