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

opendefensecloud / solution-arsenal / 26597289974

28 May 2026 07:29PM UTC coverage: 72.391% (+0.7%) from 71.738%
26597289974

Pull #554

github

web-flow
Merge b2d1d3533 into 0bb7e1b82
Pull Request #554: feat: implemented artifact cleanup

279 of 372 new or added lines in 6 files covered. (75.0%)

4 existing lines in 2 files now uncovered.

2643 of 3651 relevant lines covered (72.39%)

30.44 hits per line

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

78.11
/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
        "strings"
11
        "time"
12

13
        "github.com/google/go-containerregistry/pkg/authn"
14
        corev1 "k8s.io/api/core/v1"
15
        apierrors "k8s.io/apimachinery/pkg/api/errors"
16
        apimeta "k8s.io/apimachinery/pkg/api/meta"
17
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18
        "k8s.io/apimachinery/pkg/runtime"
19
        "k8s.io/apimachinery/pkg/types"
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/handler"
24
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
25

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

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

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

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

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

105✔
65
        log.V(1).Info("RenderArtifact is being reconciled", "req", req)
105✔
66

105✔
67
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
133✔
68
                return ctrl.Result{}, nil
28✔
69
        }
28✔
70

71
        artifact := &solarv1alpha1.RenderArtifact{}
77✔
72
        if err := r.Get(ctx, req.NamespacedName, artifact); err != nil {
85✔
73
                if apierrors.IsNotFound(err) {
16✔
74
                        return ctrl.Result{}, nil
8✔
75
                }
8✔
76

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

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

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

100
                return ctrl.Result{}, nil
8✔
101
        }
102

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

111
                return ctrl.Result{}, nil
17✔
112
        }
113

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

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

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

142
        return ctrl.Result{}, nil
37✔
143
}
144

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

15✔
152
        registryHost := strings.TrimPrefix(strings.TrimSuffix(artifact.Spec.BaseURL, "/"), "oci://")
15✔
153
        rawRef := registryHost + "/" + strings.TrimPrefix(artifact.Spec.Repository, "/") + ":" + artifact.Spec.Tag
15✔
154
        log.V(1).Info("Attempting OCI tag cleanup", "ref", rawRef)
15✔
155

15✔
156
        deleteFn := r.DeleteTag
15✔
157
        if deleteFn == nil {
15✔
NEW
158
                deleteFn = ociregistry.DeleteTag
×
NEW
159
        }
×
160

161
        auth := r.resolveAuth(ctx, artifact, registryHost)
15✔
162

15✔
163
        deleteCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
15✔
164
        defer cancel()
15✔
165
        if err := deleteFn(deleteCtx, rawRef, auth); err != nil {
22✔
166
                log.Error(err, "Failed to delete OCI tag; RenderArtifact will remain until deletion succeeds",
7✔
167
                        "ref", rawRef, "artifact", artifact.Name)
7✔
168
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
7✔
169
                        "OCICleanupFailed", "Delete",
7✔
170
                        "Failed to delete OCI tag %s: %s", rawRef, err.Error())
7✔
171

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

185
                return err
7✔
186
        }
187

188
        log.V(1).Info("OCI tag deleted successfully", "ref", rawRef)
8✔
189
        r.Recorder.Eventf(artifact, nil, corev1.EventTypeNormal,
8✔
190
                "OCICleanupSucceeded", "Delete",
8✔
191
                "Successfully deleted OCI tag %s", rawRef)
8✔
192

8✔
193
        return nil
8✔
194
}
195

196
// resolveAuth builds an authn.Authenticator from the artifact's PushSecretRef.
197
// Returns authn.Anonymous if no secret is configured or if loading fails.
198
func (r *RenderArtifactReconciler) resolveAuth(ctx context.Context, artifact *solarv1alpha1.RenderArtifact, registryHost string) authn.Authenticator {
17✔
199
        log := ctrl.LoggerFrom(ctx)
17✔
200

17✔
201
        if artifact.Spec.PushSecretRef == nil {
28✔
202
                return authn.Anonymous
11✔
203
        }
11✔
204

205
        secretNs := artifact.Namespace
6✔
206
        if artifact.Spec.PushSecretNamespace != "" {
11✔
207
                secretNs = artifact.Spec.PushSecretNamespace
5✔
208
        }
5✔
209

210
        secret := &corev1.Secret{}
6✔
211
        if err := r.Get(ctx, client.ObjectKey{
6✔
212
                Name:      artifact.Spec.PushSecretRef.Name,
6✔
213
                Namespace: secretNs,
6✔
214
        }, secret); err != nil {
11✔
215
                log.Error(err, "Failed to get push secret for OCI auth; proceeding anonymously",
5✔
216
                        "secret", artifact.Spec.PushSecretRef.Name)
5✔
217

5✔
218
                return authn.Anonymous
5✔
219
        }
5✔
220

221
        return ociAuthFromSecret(secret, registryHost)
1✔
222
}
223

224
func ociAuthFromSecret(secret *corev1.Secret, registryHost string) authn.Authenticator {
1✔
225
        if secret.Type == corev1.SecretTypeBasicAuth {
2✔
226
                user := string(secret.Data["username"])
1✔
227
                pass := string(secret.Data["password"])
1✔
228
                if user != "" || pass != "" {
2✔
229
                        return authn.FromConfig(authn.AuthConfig{Username: user, Password: pass})
1✔
230
                }
1✔
231

NEW
232
                return authn.Anonymous
×
233
        }
234

NEW
235
        data := secret.Data[corev1.DockerConfigJsonKey]
×
NEW
236
        if len(data) == 0 {
×
NEW
237
                return authn.Anonymous
×
NEW
238
        }
×
239

NEW
240
        var cfg struct {
×
NEW
241
                Auths map[string]authn.AuthConfig `json:"auths"`
×
NEW
242
        }
×
NEW
243
        if err := json.Unmarshal(data, &cfg); err != nil {
×
NEW
244
                return authn.Anonymous
×
NEW
245
        }
×
246

NEW
247
        if ac, ok := cfg.Auths[registryHost]; ok {
×
NEW
248
                return authn.FromConfig(ac)
×
NEW
249
        }
×
250

NEW
251
        if ac, ok := cfg.Auths["https://"+registryHost]; ok {
×
NEW
252
                return authn.FromConfig(ac)
×
NEW
253
        }
×
254

NEW
255
        return authn.Anonymous
×
256
}
257

258
// mapRenderBindingToArtifact maps a RenderBinding event to a reconcile request
259
// for the RenderArtifact it references, so the GC controller is triggered on
260
// every RenderBinding deletion.
261
func mapRenderBindingToArtifact(_ context.Context, obj client.Object) []reconcile.Request {
30✔
262
        rb, ok := obj.(*solarv1alpha1.RenderBinding)
30✔
263
        if !ok {
30✔
NEW
264
                return nil
×
NEW
265
        }
×
266

267
        if rb.Spec.RenderArtifactRef.Name == "" {
30✔
NEW
268
                return nil
×
NEW
269
        }
×
270

271
        return []reconcile.Request{
30✔
272
                {
30✔
273
                        NamespacedName: types.NamespacedName{
30✔
274
                                Name:      rb.Spec.RenderArtifactRef.Name,
30✔
275
                                Namespace: rb.Namespace,
30✔
276
                        },
30✔
277
                },
30✔
278
        }
30✔
279
}
280

281
// SetupWithManager sets up the controller with the Manager.
282
func (r *RenderArtifactReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
283
        return ctrl.NewControllerManagedBy(mgr).
1✔
284
                For(&solarv1alpha1.RenderArtifact{}).
1✔
285
                Watches(
1✔
286
                        &solarv1alpha1.RenderBinding{},
1✔
287
                        handler.EnqueueRequestsFromMapFunc(mapRenderBindingToArtifact),
1✔
288
                ).
1✔
289
                Complete(r)
1✔
290
}
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