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

opendefensecloud / solution-arsenal / 30822932156

03 Aug 2026 02:29PM UTC coverage: 82.109% (+0.08%) from 82.027%
30822932156

Pull #730

github

web-flow
Merge 1860c4a6a into fa9581050
Pull Request #730: fix: ignore terminating renderartifacts

69 of 80 new or added lines in 2 files covered. (86.25%)

6 existing lines in 1 file now uncovered.

5264 of 6411 relevant lines covered (82.11%)

29.1 hits per line

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

78.68
/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, insecure bool) 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=solar.opendefense.cloud,resources=registries,verbs=get
66
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=referencegrants,verbs=get;list
67
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get
68

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

191✔
72
        log.V(1).Info("RenderArtifact is being reconciled", "req", req)
191✔
73

191✔
74
        if r.WatchNamespace != "" && req.Namespace != r.WatchNamespace {
237✔
75
                return ctrl.Result{}, nil
46✔
76
        }
46✔
77

78
        artifact := &solarv1alpha1.RenderArtifact{}
145✔
79
        if err := r.Get(ctx, req.NamespacedName, artifact); err != nil {
172✔
80
                if apierrors.IsNotFound(err) {
54✔
81
                        return ctrl.Result{}, nil
27✔
82
                }
27✔
83

84
                return ctrl.Result{}, errLogAndWrap(log, err, "failed to get RenderArtifact")
×
85
        }
86

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

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

107
                return ctrl.Result{}, nil
8✔
108
        }
109

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

118
                return ctrl.Result{}, nil
26✔
119
        }
120

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

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

140
        if len(bindingList.Items) > 0 {
90✔
141
                // While at least one binding exists, keep the artifact's RegistryRef pinned to
39✔
142
                // a binding that still exists.
39✔
143
                if err := r.repinCredentials(ctx, artifact, bindingList.Items); err != nil {
39✔
144
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to re-pin RenderArtifact credentials")
×
145
                }
×
146
        } else {
12✔
147
                // If no bindings remain, trigger GC by deleting this object.
12✔
148
                // The finalizer above will intercept the deletion and handle OCI cleanup.
12✔
149
                // Confirm via direct API call — cache may lag on concurrent creates.
12✔
150
                confirmed := &solarv1alpha1.RenderBindingList{}
12✔
151
                if err := r.APIReader.List(ctx, confirmed, client.InNamespace(artifact.Namespace)); err != nil {
12✔
152
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to confirm RenderBinding absence via API")
×
153
                }
×
154
                for i := range confirmed.Items {
20✔
155
                        if confirmed.Items[i].Spec.RenderArtifactRef.Name == artifact.Name {
8✔
156
                                // A binding exists in the API server that the cache missed.
×
157
                                return ctrl.Result{}, nil
×
158
                        }
×
159
                }
160
                log.V(1).Info("No RenderBindings remain for RenderArtifact — triggering GC",
12✔
161
                        "artifact", artifact.Name)
12✔
162
                if err := r.Delete(ctx, artifact); client.IgnoreNotFound(err) != nil {
12✔
163
                        return ctrl.Result{}, errLogAndWrap(log, err, "failed to delete orphaned RenderArtifact")
×
164
                }
×
165
        }
166

167
        return ctrl.Result{}, nil
51✔
168
}
169

170
// cleanupOCIArtifact attempts to delete the OCI tag from the registry.
171
// On failure it sets a status condition and fires a Warning event so the user
172
// can see why the RenderArtifact is stuck, then returns the error to keep the
173
// finalizer in place.
174
func (r *RenderArtifactReconciler) cleanupOCIArtifact(ctx context.Context, artifact *solarv1alpha1.RenderArtifact) error {
41✔
175
        log := ctrl.LoggerFrom(ctx)
41✔
176

41✔
177
        registryHost := normalizeRegistryHost(artifact.Spec.BaseURL)
41✔
178
        rawRef := registryHost + "/" + strings.TrimPrefix(artifact.Spec.Repository, "/") + ":" + artifact.Spec.Tag
41✔
179
        log.V(1).Info("Attempting OCI tag cleanup", "ref", rawRef)
41✔
180

41✔
181
        deleteFn := r.DeleteTag
41✔
182
        if deleteFn == nil {
41✔
183
                deleteFn = ociregistry.DeleteTag
×
184
        }
×
185

186
        auth, plainHTTP, err := r.resolveAuth(ctx, artifact, registryHost)
41✔
187
        if err != nil {
67✔
188
                log.Error(err, "Failed to resolve OCI auth; RenderArtifact will remain until secret is accessible",
26✔
189
                        "artifact", artifact.Name)
26✔
190
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
26✔
191
                        "OCICleanupFailed", "Delete",
26✔
192
                        "Failed to resolve OCI auth for %s: %s", rawRef, err.Error())
26✔
193

26✔
194
                latest := artifact.DeepCopy()
26✔
195
                apimeta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{
26✔
196
                        Type:               ConditionTypeOCICleanup,
26✔
197
                        Status:             metav1.ConditionFalse,
26✔
198
                        ObservedGeneration: artifact.Generation,
26✔
199
                        Reason:             "AuthFailed",
26✔
200
                        Message:            err.Error(),
26✔
201
                })
26✔
202
                if sErr := r.Status().Patch(ctx, latest, client.MergeFrom(artifact)); sErr != nil {
26✔
NEW
203
                        log.Error(sErr, "failed to update status condition after OCI auth failure")
×
NEW
204
                }
×
205

206
                return err
26✔
207
        }
208

209
        deleteCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
15✔
210
        defer cancel()
15✔
211
        if err := deleteFn(deleteCtx, rawRef, auth, plainHTTP); err != nil {
23✔
212
                // If the tag is already gone, proceed normally.
8✔
213
                var transportErr *transport.Error
8✔
214
                if errors.As(err, &transportErr) && transportErr.StatusCode == http.StatusNotFound {
9✔
215
                        log.V(1).Info("OCI tag already absent — skipping delete", "ref", rawRef)
1✔
216
                        return nil
1✔
217
                }
1✔
218

219
                log.Error(err, "Failed to delete OCI tag; RenderArtifact will remain until deletion succeeds",
7✔
220
                        "ref", rawRef, "artifact", artifact.Name)
7✔
221
                r.Recorder.Eventf(artifact, nil, corev1.EventTypeWarning,
7✔
222
                        "OCICleanupFailed", "Delete",
7✔
223
                        "Failed to delete OCI tag %s: %s", rawRef, err.Error())
7✔
224

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

238
                return err
7✔
239
        }
240

241
        log.V(1).Info("OCI tag deleted successfully", "ref", rawRef)
7✔
242
        r.Recorder.Eventf(artifact, nil, corev1.EventTypeNormal,
7✔
243
                "OCICleanupSucceeded", "Delete",
7✔
244
                "Successfully deleted OCI tag %s", rawRef)
7✔
245

7✔
246
        return nil
7✔
247
}
248

249
// repinCredentials keeps artifact.Spec.RegistryRef pinned to the Registry snapshotted on
250
// a still-existing RenderBinding. Bindings that carry no RegistryRef are not candidates;
251
// among the rest the lowest name wins, so repeated reconciles converge instead of flapping
252
// between equally-valid choices.
253
// Because this runs on every RenderBinding create/update/delete event (see
254
// mapRenderBindingToArtifact), the artifact's pinned RegistryRef is always synced to a
255
// binding that exists, including immediately after the second-to-last binding is
256
// removed, which is exactly the moment that matters: it leaves the artifact holding a
257
// Registry reference that was valid for the binding that survives until the final
258
// removal, which is what the finalizer step needs to delete the OCI tag.
259
func (r *RenderArtifactReconciler) repinCredentials(ctx context.Context, artifact *solarv1alpha1.RenderArtifact, bindings []solarv1alpha1.RenderBinding) error {
39✔
260
        slices.SortFunc(bindings, func(a, b solarv1alpha1.RenderBinding) int { return strings.Compare(a.Name, b.Name) })
41✔
261

262
        // RegistryRef is optional, so bindings written before it existed carry nil. Skip those
263
        // instead of pinning nil over a working reference
264
        // If no binding carries a reference, keep what the artifact
265
        // already has: a stale-but-valid ref deletes the tag, nil does not.
266
        idx := slices.IndexFunc(bindings, func(b solarv1alpha1.RenderBinding) bool {
80✔
267
                return b.Spec.RegistryRef != nil
41✔
268
        })
41✔
269
        if idx < 0 {
47✔
270
                return nil
8✔
271
        }
8✔
272
        chosen := bindings[idx]
31✔
273

31✔
274
        if registryRefEqual(artifact.Spec.RegistryRef, chosen.Spec.RegistryRef) {
59✔
275
                return nil
28✔
276
        }
28✔
277

278
        latest := artifact.DeepCopy()
3✔
279
        latest.Spec.RegistryRef = chosen.Spec.RegistryRef
3✔
280

3✔
281
        return r.Patch(ctx, latest, client.MergeFrom(artifact))
3✔
282
}
283

284
func registryRefEqual(a, b *solarv1alpha1.ObjectReference) bool {
78✔
285
        if a == nil || b == nil {
78✔
286
                return a == b
×
287
        }
×
288

289
        return *a == *b
78✔
290
}
291

292
func (r *RenderArtifactReconciler) resolveAuth(ctx context.Context, artifact *solarv1alpha1.RenderArtifact, registryHost string) (authn.Authenticator, bool, error) {
46✔
293
        log := ctrl.LoggerFrom(ctx)
46✔
294

46✔
295
        if artifact.Spec.RegistryRef == nil {
59✔
296
                return authn.Anonymous, false, nil
13✔
297
        }
13✔
298

299
        registryNamespace := artifact.Namespace
33✔
300
        if artifact.Spec.RegistryRef.Namespace != "" {
36✔
301
                registryNamespace = artifact.Spec.RegistryRef.Namespace
3✔
302
        }
3✔
303

304
        // RegistryRef is meant to be controller-owned, but nothing stops a principal with
305
        // create/update on RenderArtifact from authoring one. Riding the Target's grant would
306
        // then hand those credentials to anyone who can write a RenderArtifact in a namespace
307
        // some Target happens to be granted from, so the grant must name RenderArtifact itself.
308
        if registryNamespace != artifact.Namespace {
36✔
309
                granted, err := registryGranted(ctx, r.APIReader, registryNamespace, "RenderArtifact", artifact.Namespace)
3✔
310
                if err != nil {
3✔
311
                        return nil, false, fmt.Errorf("failed to check ReferenceGrant for Registry %s/%s: %w",
×
312
                                registryNamespace, artifact.Spec.RegistryRef.Name, err)
×
313
                }
×
314
                if !granted {
5✔
315
                        return nil, false, fmt.Errorf(
2✔
316
                                "no ReferenceGrant in namespace %s with from[].kind=RenderArtifact, from[].namespace=%s and to[].kind=Registry "+
2✔
317
                                        "allows RenderArtifact %s/%s to access Registry %s/%s",
2✔
318
                                registryNamespace, artifact.Namespace,
2✔
319
                                artifact.Namespace, artifact.Name, registryNamespace, artifact.Spec.RegistryRef.Name)
2✔
320
                }
2✔
321
        }
322

323
        registry := &solarv1alpha1.Registry{}
31✔
324
        if err := r.APIReader.Get(ctx, client.ObjectKey{
31✔
325
                Name:      artifact.Spec.RegistryRef.Name,
31✔
326
                Namespace: registryNamespace,
31✔
327
        }, registry); err != nil {
31✔
328
                return nil, false, fmt.Errorf("failed to get Registry %s/%s: %w", registryNamespace, artifact.Spec.RegistryRef.Name, err)
×
329
        }
×
330

331
        // The grant authorizes this namespace to use the Registry, not to use its credentials
332
        // against an arbitrary host. spec.baseURL is what the delete is aimed at, and a Registry
333
        // secret may hold auths for several hosts, so refuse unless the artifact points at the
334
        // Registry's own hostname. Artifacts the Target controller produced always do
335
        if artifactHost, registryHostname := registryHost, normalizeRegistryHost(registry.Spec.Hostname); artifactHost != registryHostname {
32✔
336
                return nil, false, fmt.Errorf(
1✔
337
                        "RenderArtifact %s/%s targets host %q but Registry %s/%s serves %q; refusing to use its credentials",
1✔
338
                        artifact.Namespace, artifact.Name, artifactHost, registryNamespace, registry.Name, registryHostname)
1✔
339
        }
1✔
340

341
        if registry.Spec.SolarSecretRef == nil {
31✔
342
                return authn.Anonymous, registry.Spec.PlainHTTP, nil
1✔
343
        }
1✔
344

345
        secret := &corev1.Secret{}
29✔
346
        if err := r.Get(ctx, client.ObjectKey{
29✔
347
                Name:      registry.Spec.SolarSecretRef.Name,
29✔
348
                Namespace: registry.Namespace,
29✔
349
        }, secret); err != nil {
55✔
350
                log.Error(err, "Failed to get push secret for OCI auth",
26✔
351
                        "secret", registry.Spec.SolarSecretRef.Name)
26✔
352

26✔
353
                return nil, false, fmt.Errorf("failed to get push secret %s/%s: %w", registry.Namespace, registry.Spec.SolarSecretRef.Name, err)
26✔
354
        }
26✔
355

356
        auth, err := ociAuthFromSecret(secret, registryHost)
3✔
357
        if err != nil {
3✔
358
                // A malformed dockerconfigjson is a configuration error; log it so the operator
×
359
                // is aware, but fall back to anonymous rather than blocking OCI cleanup.
×
360
                log.Error(err, "Malformed push secret; falling back to anonymous OCI auth",
×
361
                        "secret", fmt.Sprintf("%s/%s", registry.Namespace, registry.Spec.SolarSecretRef.Name))
×
362
        }
×
363

364
        return auth, registry.Spec.PlainHTTP, nil
3✔
365
}
366

367
// normalizeRegistryHost strips the oci:// scheme and any trailing slash so a
368
// Registry hostname and an artifact baseURL can be compared as written by either side.
369
func normalizeRegistryHost(s string) string {
72✔
370
        return strings.TrimPrefix(strings.TrimSuffix(s, "/"), "oci://")
72✔
371
}
72✔
372

373
// ociAuthFromSecret extracts OCI credentials from a Kubernetes Secret.
374
// callers should log the error and decide whether to fall back to anonymous or abort.
375
func ociAuthFromSecret(secret *corev1.Secret, registryHost string) (authn.Authenticator, error) {
3✔
376
        if secret.Type == corev1.SecretTypeBasicAuth {
6✔
377
                user := string(secret.Data["username"])
3✔
378
                pass := string(secret.Data["password"])
3✔
379
                if user != "" || pass != "" {
6✔
380
                        return authn.FromConfig(authn.AuthConfig{Username: user, Password: pass}), nil
3✔
381
                }
3✔
382

383
                return authn.Anonymous, nil
×
384
        }
385

386
        data := secret.Data[corev1.DockerConfigJsonKey]
×
387
        if len(data) == 0 {
×
388
                return authn.Anonymous, nil
×
389
        }
×
390

391
        var cfg struct {
×
392
                Auths map[string]authn.AuthConfig `json:"auths"`
×
393
        }
×
394
        if err := json.Unmarshal(data, &cfg); err != nil {
×
395
                return authn.Anonymous, fmt.Errorf("failed to parse dockerconfigjson in secret %s/%s: %w", secret.Namespace, secret.Name, err)
×
396
        }
×
397

398
        if ac, ok := cfg.Auths[registryHost]; ok {
×
399
                return authn.FromConfig(ac), nil
×
400
        }
×
401

402
        if ac, ok := cfg.Auths["https://"+registryHost]; ok {
×
403
                return authn.FromConfig(ac), nil
×
404
        }
×
405

406
        return authn.Anonymous, nil
×
407
}
408

409
// mapRenderBindingToArtifact maps a RenderBinding event to a reconcile request
410
// for the RenderArtifact it references, so the GC controller is triggered on
411
// every RenderBinding deletion.
412
func mapRenderBindingToArtifact(_ context.Context, obj client.Object) []reconcile.Request {
48✔
413
        rb, ok := obj.(*solarv1alpha1.RenderBinding)
48✔
414
        if !ok {
48✔
415
                return nil
×
416
        }
×
417

418
        if rb.Spec.RenderArtifactRef.Name == "" {
48✔
419
                return nil
×
420
        }
×
421

422
        return []reconcile.Request{
48✔
423
                {
48✔
424
                        NamespacedName: types.NamespacedName{
48✔
425
                                Name:      rb.Spec.RenderArtifactRef.Name,
48✔
426
                                Namespace: rb.Namespace,
48✔
427
                        },
48✔
428
                },
48✔
429
        }
48✔
430
}
431

432
// SetupWithManager sets up the controller with the Manager.
433
func (r *RenderArtifactReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
434
        return ctrl.NewControllerManagedBy(mgr).
1✔
435
                For(&solarv1alpha1.RenderArtifact{}).
1✔
436
                Watches(
1✔
437
                        &solarv1alpha1.RenderBinding{},
1✔
438
                        handler.EnqueueRequestsFromMapFunc(mapRenderBindingToArtifact),
1✔
439
                ).
1✔
440
                Complete(r)
1✔
441
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc