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

opendefensecloud / solution-arsenal / 27404628011

12 Jun 2026 08:36AM UTC coverage: 74.416% (+0.3%) from 74.122%
27404628011

push

github

web-flow
feat: implemented artifact cleanup (#554)

## What
Clean up rendered OCI artifacts and `RenderTask`s when SolAr API
resources are deleted.
Closes #358

## Why
Deleting a `ReleaseBinding` or `Target` previously left orphaned
`RenderTask` objects and OCI images in the render registry. This
implements the full deletion lifecycle.

## Testing
- Unit tests: `RenderArtifactReconciler` covered with fake client +
injected `DeleteTag` stub (`pkg/controller`)
- Unit tests: `TargetReconciler` extended for `ensureRenderArtifact`,
`ensureRenderBinding`, and `deleteStaleRenderBindings`
- `pkg/ociregistry.DeleteTag` tested for success and error paths
- E2E:
  - 1 artifact + 1 binding created on first render
- Same artifact reused (2 bindings) when a second Target renders the
same release
  - Artifact survives removal of one binding while another remains
- Artifact GC'd and OCI tag deleted after last binding removed via
Target deletion (finalizer path)
- Artifact GC'd and OCI tag deleted after last ReleaseBinding deleted
directly (stale-binding path)


## Notes for reviewers
**New API resources (CRD-equivalent, served via extension apiserver):**
- `RenderArtifact` records OCI coordinates of a pushed chart artifact;
carries a finalizer; reconciled by `RenderArtifactReconciler`
- `RenderBinding` ref-count record linking a Target to a
`RenderArtifact`; drives GC when the last binding is removed

**Helm chart:** top-level `caBundle` block added to mount a CA bundle
`ConfigMap` into the controller-manager pod (`SSL_CERT_FILE`), required
for TLS to self-signed registries in cluster.

## Checklist
- [x] Tests added/updated
- [x] No breaking changes (or upgrade path documented above)
- [x] Readable commit history (squashed and cleaned up as desired)
- [x] AI code review considered and comments resolved


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added RenderArtifact and RenderBinding CRDs, typed
... (continued)

312 of 425 new or added lines in 6 files covered. (73.41%)

2 existing lines in 1 file now uncovered.

2964 of 3983 relevant lines covered (74.42%)

41.55 hits per line

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

76.78
/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
        "errors"
10
        "fmt"
11
        "net/http"
12
        "slices"
13
        "strings"
14
        "time"
15

16
        "github.com/google/go-containerregistry/pkg/authn"
17
        "github.com/google/go-containerregistry/pkg/v1/remote/transport"
18
        corev1 "k8s.io/api/core/v1"
19
        apierrors "k8s.io/apimachinery/pkg/api/errors"
20
        apimeta "k8s.io/apimachinery/pkg/api/meta"
21
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22
        "k8s.io/apimachinery/pkg/runtime"
23
        "k8s.io/apimachinery/pkg/types"
24
        "k8s.io/client-go/tools/events"
25
        ctrl "sigs.k8s.io/controller-runtime"
26
        "sigs.k8s.io/controller-runtime/pkg/client"
27
        "sigs.k8s.io/controller-runtime/pkg/handler"
28
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
29

30
        solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1"
31
        "go.opendefense.cloud/solar/pkg/ociregistry"
32
)
33

34
const (
35
        renderArtifactFinalizer = "solar.opendefense.cloud/render-artifact-finalizer"
36
        ConditionTypeOCICleanup = "OCICleanup"
37
)
38

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

61
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts,verbs=get;list;watch;update;patch;delete
62
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts/status,verbs=get;update;patch
63
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderartifacts/finalizers,verbs=update
64
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=renderbindings,verbs=get;list;watch
65
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get
66

67
func (r *RenderArtifactReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
145✔
68
        log := ctrl.LoggerFrom(ctx)
145✔
69

145✔
70
        log.V(1).Info("RenderArtifact is being reconciled", "req", req)
145✔
71

145✔
72
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
177✔
73
                return ctrl.Result{}, nil
32✔
74
        }
32✔
75

76
        artifact := &solarv1alpha1.RenderArtifact{}
113✔
77
        if err := r.Get(ctx, req.NamespacedName, artifact); err != nil {
129✔
78
                if apierrors.IsNotFound(err) {
32✔
79
                        return ctrl.Result{}, nil
16✔
80
                }
16✔
81

NEW
82
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to get RenderArtifact")
×
83
        }
84

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

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

105
                return ctrl.Result{}, nil
5✔
106
        }
107

108
        // Ensure finalizer is set.
109
        if !slices.Contains(artifact.Finalizers, renderArtifactFinalizer) {
75✔
110
                latest := artifact.DeepCopy()
18✔
111
                latest.Finalizers = append(latest.Finalizers, renderArtifactFinalizer)
18✔
112
                if err := r.Patch(ctx, latest, client.MergeFrom(artifact)); err != nil {
18✔
NEW
113
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to add finalizer to RenderArtifact")
×
NEW
114
                }
×
115

116
                return ctrl.Result{}, nil
18✔
117
        }
118

119
        // Populate status.ChartURL from spec coordinates if not yet set.
120
        chartURL := renderChartURL(artifact.Spec.BaseURL, artifact.Spec.Repository, artifact.Spec.Tag)
39✔
121
        if artifact.Status.ChartURL != chartURL {
57✔
122
                base := artifact.DeepCopy()
18✔
123
                artifact.Status.ChartURL = chartURL
18✔
124
                if err := r.Status().Patch(ctx, artifact, client.MergeFrom(base)); err != nil {
18✔
NEW
125
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to update RenderArtifact status")
×
NEW
126
                }
×
127
        }
128

129
        // List RenderBindings referencing this artifact.
130
        bindingList := &solarv1alpha1.RenderBindingList{}
39✔
131
        if err := r.List(ctx, bindingList,
39✔
132
                client.InNamespace(artifact.Namespace),
39✔
133
                client.MatchingFields{indexRenderBindingArtifactName: artifact.Name},
39✔
134
        ); err != nil {
39✔
NEW
135
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to list RenderBindings for RenderArtifact")
×
NEW
136
        }
×
137

138
        // If no bindings remain, trigger GC by deleting this object.
139
        // The finalizer above will intercept the deletion and handle OCI cleanup.
140
        if len(bindingList.Items) == 0 {
48✔
141
                // Confirm via direct API call — cache may lag on concurrent creates.
9✔
142
                confirmed := &solarv1alpha1.RenderBindingList{}
9✔
143
                if err := r.APIReader.List(ctx, confirmed, client.InNamespace(artifact.Namespace)); err != nil {
9✔
NEW
144
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to confirm RenderBinding absence via API")
×
NEW
145
                }
×
146
                for i := range confirmed.Items {
17✔
147
                        if confirmed.Items[i].Spec.RenderArtifactRef.Name == artifact.Name {
8✔
NEW
148
                                // A binding exists in the API server that the cache missed.
×
NEW
149
                                return ctrl.Result{}, nil
×
NEW
150
                        }
×
151
                }
152
                log.V(1).Info("No RenderBindings remain for RenderArtifact — triggering GC",
9✔
153
                        "artifact", artifact.Name)
9✔
154
                if err := r.Delete(ctx, artifact); client.IgnoreNotFound(err) != nil {
9✔
NEW
155
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to delete orphaned RenderArtifact")
×
NEW
156
                }
×
157
        }
158

159
        return ctrl.Result{}, nil
39✔
160
}
161

162
// cleanupOCIArtifact attempts to delete the OCI tag from the registry.
163
// On failure it sets a status condition and fires a Warning event so the user
164
// can see why the RenderArtifact is stuck, then returns the error to keep the
165
// finalizer in place.
166
func (r *RenderArtifactReconciler) cleanupOCIArtifact(ctx context.Context, artifact *solarv1alpha1.RenderArtifact) error {
40✔
167
        log := ctrl.LoggerFrom(ctx)
40✔
168

40✔
169
        registryHost := strings.TrimPrefix(strings.TrimSuffix(artifact.Spec.BaseURL, "/"), "oci://")
40✔
170
        rawRef := registryHost + "/" + strings.TrimPrefix(artifact.Spec.Repository, "/") + ":" + artifact.Spec.Tag
40✔
171
        log.V(1).Info("Attempting OCI tag cleanup", "ref", rawRef)
40✔
172

40✔
173
        deleteFn := r.DeleteTag
40✔
174
        if deleteFn == nil {
40✔
NEW
175
                deleteFn = ociregistry.DeleteTag
×
NEW
176
        }
×
177

178
        auth, err := r.resolveAuth(ctx, artifact, registryHost)
40✔
179
        if err != nil {
68✔
180
                log.Error(err, "Failed to resolve OCI auth; RenderArtifact will remain until secret is accessible",
28✔
181
                        "artifact", artifact.Name)
28✔
182
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
28✔
183
                        "OCICleanupFailed", "Delete",
28✔
184
                        "Failed to resolve OCI auth for %s: %s", rawRef, err.Error())
28✔
185

28✔
186
                latest := artifact.DeepCopy()
28✔
187
                apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{
28✔
188
                        Type:               ConditionTypeOCICleanup,
28✔
189
                        Status:             metav1.ConditionFalse,
28✔
190
                        ObservedGeneration: artifact.Generation,
28✔
191
                        Reason:             "AuthFailed",
28✔
192
                        Message:            err.Error(),
28✔
193
                })
28✔
194
                if sErr := r.Status().Patch(ctx, latest, client.MergeFrom(artifact)); sErr != nil {
28✔
NEW
195
                        log.Error(sErr, "failed to update status condition after OCI auth failure")
×
NEW
196
                }
×
197

198
                return err
28✔
199
        }
200

201
        deleteCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
12✔
202
        defer cancel()
12✔
203
        if err := deleteFn(deleteCtx, rawRef, auth); err != nil {
20✔
204
                // If the tag is already gone, proceed normally.
8✔
205
                var transportErr *transport.Error
8✔
206
                if errors.As(err, &transportErr) && transportErr.StatusCode == http.StatusNotFound {
9✔
207
                        log.V(1).Info("OCI tag already absent — skipping delete", "ref", rawRef)
1✔
208
                        return nil
1✔
209
                }
1✔
210

211
                log.Error(err, "Failed to delete OCI tag; RenderArtifact will remain until deletion succeeds",
7✔
212
                        "ref", rawRef, "artifact", artifact.Name)
7✔
213
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
7✔
214
                        "OCICleanupFailed", "Delete",
7✔
215
                        "Failed to delete OCI tag %s: %s", rawRef, err.Error())
7✔
216

7✔
217
                latest := artifact.DeepCopy()
7✔
218
                apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{
7✔
219
                        Type:               ConditionTypeOCICleanup,
7✔
220
                        Status:             metav1.ConditionFalse,
7✔
221
                        ObservedGeneration: artifact.Generation,
7✔
222
                        Reason:             "DeleteFailed",
7✔
223
                        Message:            err.Error(),
7✔
224
                })
7✔
225
                // Status patch, if it fails, the event + log are visible in kubectl
7✔
226
                if sErr := r.Status().Patch(ctx, latest, client.MergeFrom(artifact)); sErr != nil {
7✔
NEW
227
                        log.Error(sErr, "failed to update status condition after OCI cleanup failure")
×
NEW
228
                }
×
229

230
                return err
7✔
231
        }
232

233
        log.V(1).Info("OCI tag deleted successfully", "ref", rawRef)
4✔
234
        r.Recorder.Eventf(artifact, nil, corev1.EventTypeNormal,
4✔
235
                "OCICleanupSucceeded", "Delete",
4✔
236
                "Successfully deleted OCI tag %s", rawRef)
4✔
237

4✔
238
        return nil
4✔
239
}
240

241
// resolveAuth builds an authn.Authenticator from the artifact's PushSecretRef.
242
// Returns authn.Anonymous if no secret is configured or if loading fails.
243
func (r *RenderArtifactReconciler) resolveAuth(ctx context.Context, artifact *solarv1alpha1.RenderArtifact, registryHost string) (authn.Authenticator, error) {
42✔
244
        log := ctrl.LoggerFrom(ctx)
42✔
245

42✔
246
        if artifact.Spec.PushSecretRef == nil {
54✔
247
                return authn.Anonymous, nil
12✔
248
        }
12✔
249

250
        secretNs := artifact.Namespace
30✔
251
        if artifact.Spec.PushSecretNamespace != "" {
59✔
252
                secretNs = artifact.Spec.PushSecretNamespace
29✔
253
        }
29✔
254

255
        secret := &corev1.Secret{}
30✔
256
        if err := r.Get(ctx, client.ObjectKey{
30✔
257
                Name:      artifact.Spec.PushSecretRef.Name,
30✔
258
                Namespace: secretNs,
30✔
259
        }, secret); err != nil {
59✔
260
                log.Error(err, "Failed to get push secret for OCI auth",
29✔
261
                        "secret", artifact.Spec.PushSecretRef.Name)
29✔
262

29✔
263
                return nil, fmt.Errorf("failed to get push secret %s/%s: %w", secretNs, artifact.Spec.PushSecretRef.Name, err)
29✔
264
        }
29✔
265

266
        auth, err := ociAuthFromSecret(secret, registryHost)
1✔
267
        if err != nil {
1✔
NEW
268
                // A malformed dockerconfigjson is a configuration error; log it so the operator
×
NEW
269
                // is aware, but fall back to anonymous rather than blocking OCI cleanup.
×
NEW
270
                log.Error(err, "Malformed push secret; falling back to anonymous OCI auth",
×
NEW
271
                        "secret", fmt.Sprintf("%s/%s", secretNs, artifact.Spec.PushSecretRef.Name))
×
NEW
272
        }
×
273

274
        return auth, nil
1✔
275
}
276

277
// ociAuthFromSecret extracts OCI credentials from a Kubernetes Secret.
278
// callers should log the error and decide whether to fall back to anonymous or abort.
279
func ociAuthFromSecret(secret *corev1.Secret, registryHost string) (authn.Authenticator, error) {
1✔
280
        if secret.Type == corev1.SecretTypeBasicAuth {
2✔
281
                user := string(secret.Data["username"])
1✔
282
                pass := string(secret.Data["password"])
1✔
283
                if user != "" || pass != "" {
2✔
284
                        return authn.FromConfig(authn.AuthConfig{Username: user, Password: pass}), nil
1✔
285
                }
1✔
286

NEW
287
                return authn.Anonymous, nil
×
288
        }
289

NEW
290
        data := secret.Data[corev1.DockerConfigJsonKey]
×
NEW
291
        if len(data) == 0 {
×
NEW
292
                return authn.Anonymous, nil
×
NEW
293
        }
×
294

NEW
295
        var cfg struct {
×
NEW
296
                Auths map[string]authn.AuthConfig `json:"auths"`
×
NEW
297
        }
×
NEW
298
        if err := json.Unmarshal(data, &cfg); err != nil {
×
NEW
299
                return authn.Anonymous, fmt.Errorf("failed to parse dockerconfigjson in secret %s/%s: %w", secret.Namespace, secret.Name, err)
×
NEW
300
        }
×
301

NEW
302
        if ac, ok := cfg.Auths[registryHost]; ok {
×
NEW
303
                return authn.FromConfig(ac), nil
×
NEW
304
        }
×
305

NEW
306
        if ac, ok := cfg.Auths["https://"+registryHost]; ok {
×
NEW
307
                return authn.FromConfig(ac), nil
×
NEW
308
        }
×
309

NEW
310
        return authn.Anonymous, nil
×
311
}
312

313
// mapRenderBindingToArtifact maps a RenderBinding event to a reconcile request
314
// for the RenderArtifact it references, so the GC controller is triggered on
315
// every RenderBinding deletion.
316
func mapRenderBindingToArtifact(_ context.Context, obj client.Object) []reconcile.Request {
30✔
317
        rb, ok := obj.(*solarv1alpha1.RenderBinding)
30✔
318
        if !ok {
30✔
NEW
319
                return nil
×
NEW
320
        }
×
321

322
        if rb.Spec.RenderArtifactRef.Name == "" {
30✔
NEW
323
                return nil
×
NEW
324
        }
×
325

326
        return []reconcile.Request{
30✔
327
                {
30✔
328
                        NamespacedName: types.NamespacedName{
30✔
329
                                Name:      rb.Spec.RenderArtifactRef.Name,
30✔
330
                                Namespace: rb.Namespace,
30✔
331
                        },
30✔
332
                },
30✔
333
        }
30✔
334
}
335

336
// SetupWithManager sets up the controller with the Manager.
337
func (r *RenderArtifactReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
338
        return ctrl.NewControllerManagedBy(mgr).
1✔
339
                For(&solarv1alpha1.RenderArtifact{}).
1✔
340
                Watches(
1✔
341
                        &solarv1alpha1.RenderBinding{},
1✔
342
                        handler.EnqueueRequestsFromMapFunc(mapRenderBindingToArtifact),
1✔
343
                ).
1✔
344
                Complete(r)
1✔
345
}
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