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

opendefensecloud / solution-arsenal / 21625634450

03 Feb 2026 09:59AM UTC coverage: 70.779% (+5.3%) from 65.488%
21625634450

Pull #92

github

web-flow
Merge 65c34838e into 25e2e0b69
Pull Request #92: Add controllers to schedule rendering jobs

269 of 335 new or added lines in 3 files covered. (80.3%)

27 existing lines in 2 files now uncovered.

763 of 1078 relevant lines covered (70.78%)

8.42 hits per line

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

80.97
/pkg/controller/release_controller.go
1
// Copyright 2026 BWI GmbH and Artefact Conduit contributors
2
// SPDX-License-Identifier: Apache-2.0
3

4
package controller
5

6
import (
7
        "context"
8
        "encoding/json"
9
        "fmt"
10
        "slices"
11
        "time"
12

13
        "github.com/go-logr/logr"
14
        solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1"
15
        "go.opendefense.cloud/solar/pkg/renderer"
16
        batchv1 "k8s.io/api/batch/v1"
17
        corev1 "k8s.io/api/core/v1"
18
        apierrors "k8s.io/apimachinery/pkg/api/errors"
19
        apimeta "k8s.io/apimachinery/pkg/api/meta"
20
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
21
        "k8s.io/apimachinery/pkg/runtime"
22
        "k8s.io/client-go/tools/record"
23
        ctrl "sigs.k8s.io/controller-runtime"
24

25
        "sigs.k8s.io/controller-runtime/pkg/client"
26
)
27

28
const (
29
        releaseFinalizer = "solar.opendefense.cloud/release-finalizer"
30

31
        // Condition types
32
        ConditionTypeJobScheduled = "JobScheduled"
33
        ConditionTypeJobSucceeded = "JobSucceeded"
34
        ConditionTypeJobFailed    = "JobFailed"
35

36
        // Annotation for tracking job/secret ownership
37
        AnnotationJobName    = "solar.opendefense.cloud/job-name"
38
        AnnotationSecretName = "solar.opendefense.cloud/config-secret-name"
39
)
40

41
// ReleaseReconciler reconciles a Release object
42
type ReleaseReconciler struct {
43
        client.Client
44
        Scheme          *runtime.Scheme
45
        Recorder        record.EventRecorder
46
        RendererImage   string
47
        RendererCommand string
48
        RendererArgs    []string
49
        PushOptions     renderer.PushOptions
50
}
51

52
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases,verbs=get;list;watch;create;update;patch;delete
53
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases/status,verbs=get;update;patch
54
//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=releases/finalizers,verbs=update
55
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
56
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
57
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
58

59
// Reconcile moves the current state of the cluster closer to the desired state
60
//
61
// Reconciliation Flow:
62
//
63
//        Release created
64
//            ↓
65
//        Add finalizer
66
//            ↓
67
//        Check if already succeeded → YES → Return (no-op)
68
//            ↓ NO
69
//        Create/update config secret
70
//            ↓
71
//        Get or create job
72
//            ↓
73
//        Update release status from job
74
//            ↓
75
//        Job completed with success?
76
//            ├→ YES → Cleanup resources → Return
77
//            └→ NO → Still running? → Requeue in 5s
78
func (r *ReleaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
41✔
79
        log := ctrl.LoggerFrom(ctx)
41✔
80
        ctrlResult := ctrl.Result{}
41✔
81

41✔
82
        log.V(1).Info("Release is being reconciled", "req", req)
41✔
83

41✔
84
        // Fetch the Release instance
41✔
85
        res := &solarv1alpha1.Release{}
41✔
86
        if err := r.Get(ctx, req.NamespacedName, res); err != nil {
43✔
87
                if apierrors.IsNotFound(err) {
4✔
88
                        // Object not found, return. Created objects are automatically garbage collected.
2✔
89
                        return ctrlResult, nil
2✔
90
                }
2✔
NEW
91
                return ctrlResult, errLogAndWrap(log, err, "failed to get object")
×
92
        }
93

94
        // Handle deletion: cleanup job and secret, then remove finalizer
95
        if !res.DeletionTimestamp.IsZero() {
40✔
96
                log.V(1).Info("Release is being deleted")
1✔
97
                r.Recorder.Event(res, corev1.EventTypeWarning, "Deleting", "Release is being deleted, cleaning up resources")
1✔
98

1✔
99
                if err := r.cleanupResources(ctx, log, res); err != nil {
1✔
NEW
100
                        return ctrlResult, errLogAndWrap(log, err, "failed to cleanup resources")
×
NEW
101
                }
×
102

103
                // Remove finalizer
104
                if slices.Contains(res.Finalizers, releaseFinalizer) {
2✔
105
                        log.V(1).Info("Removing finalizer from resource")
1✔
106
                        res.Finalizers = slices.DeleteFunc(res.Finalizers, func(f string) bool {
2✔
107
                                return f == releaseFinalizer
1✔
108
                        })
1✔
109
                        if err := r.Update(ctx, res); err != nil {
1✔
NEW
110
                                return ctrlResult, errLogAndWrap(log, err, "failed to remove finalizer")
×
NEW
111
                        }
×
112
                }
113
                return ctrlResult, nil
1✔
114
        }
115

116
        // Add finalizer if not present
117
        if !slices.Contains(res.Finalizers, releaseFinalizer) {
46✔
118
                log.V(1).Info("Adding finalizer to resource")
8✔
119
                res.Finalizers = append(res.Finalizers, releaseFinalizer)
8✔
120
                if err := r.Update(ctx, res); err != nil {
8✔
NEW
121
                        return ctrlResult, errLogAndWrap(log, err, "failed to add finalizer")
×
NEW
122
                }
×
123
                // Return without requeue; the Update event will trigger reconciliation again
124
                return ctrlResult, nil
8✔
125
        }
126

127
        // Check if release has already completed successfully
128
        if apimeta.IsStatusConditionTrue(res.Status.Conditions, ConditionTypeJobSucceeded) {
35✔
129
                log.V(1).Info("Release has already completed successfully, no further action needed")
5✔
130
                return ctrlResult, nil
5✔
131
        }
5✔
132

133
        // Create or update configuration secret
134
        configSecret, err := r.createOrUpdateConfigSecret(ctx, log, res)
25✔
135
        if err != nil {
25✔
NEW
136
                r.Recorder.Event(res, corev1.EventTypeWarning, "ConfigFailed", fmt.Sprintf("Failed to create config secret: %v", err))
×
NEW
137
                apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
×
NEW
138
                        Type:               ConditionTypeJobScheduled,
×
NEW
139
                        Status:             metav1.ConditionFalse,
×
NEW
140
                        ObservedGeneration: res.Generation,
×
NEW
141
                        Reason:             "ConfigSecretFailed",
×
NEW
142
                        Message:            fmt.Sprintf("Failed to create config secret: %v", err),
×
NEW
143
                })
×
NEW
144
                if err := r.Status().Update(ctx, res); err != nil {
×
NEW
145
                        log.Error(err, "failed to update Release status")
×
NEW
146
                }
×
NEW
147
                return ctrlResult, errLogAndWrap(log, err, "failed to create config secret")
×
148
        }
149

150
        res.Status.ConfigSecretRef = &corev1.ObjectReference{
25✔
151
                APIVersion: "v1",
25✔
152
                Kind:       "Secret",
25✔
153
                Name:       configSecret.Name,
25✔
154
                Namespace:  configSecret.Namespace,
25✔
155
                UID:        configSecret.UID,
25✔
156
        }
25✔
157

25✔
158
        // Get or create the job
25✔
159
        job, err := r.getOrCreateJob(ctx, log, res, configSecret)
25✔
160
        if err != nil {
25✔
NEW
161
                r.Recorder.Event(res, corev1.EventTypeWarning, "JobFailed", fmt.Sprintf("Failed to create or get job: %v", err))
×
NEW
162
                apimeta.SetStatusCondition(&res.Status.Conditions, metav1.Condition{
×
NEW
163
                        Type:               ConditionTypeJobScheduled,
×
NEW
164
                        Status:             metav1.ConditionFalse,
×
NEW
165
                        ObservedGeneration: res.Generation,
×
NEW
166
                        Reason:             "JobCreationFailed",
×
NEW
167
                        Message:            fmt.Sprintf("Failed to create job: %v", err),
×
NEW
168
                })
×
NEW
169
                if err := r.Status().Update(ctx, res); err != nil {
×
NEW
170
                        log.Error(err, "failed to update Release status")
×
NEW
171
                }
×
NEW
172
                return ctrlResult, errLogAndWrap(log, err, "failed to create job")
×
173
        }
174

175
        if job != nil {
50✔
176
                res.Status.JobRef = &corev1.ObjectReference{
25✔
177
                        APIVersion: "batch/v1",
25✔
178
                        Kind:       "Job",
25✔
179
                        Name:       job.Name,
25✔
180
                        Namespace:  job.Namespace,
25✔
181
                        UID:        job.UID,
25✔
182
                }
25✔
183

25✔
184
                // Check job status
25✔
185
                if err := r.updateReleaseStatusFromJob(ctx, log, res, job); err != nil {
25✔
NEW
186
                        return ctrlResult, errLogAndWrap(log, err, "failed to update Release status from job")
×
NEW
187
                }
×
188

189
                // Update the Release status
190
                if err := r.Status().Update(ctx, res); err != nil {
25✔
NEW
191
                        return ctrlResult, errLogAndWrap(log, err, "failed to update Release status")
×
NEW
192
                }
×
193

194
                // Check if job completed successfully
195
                if isJobComplete(job) && job.Status.Succeeded > 0 {
27✔
196
                        log.V(1).Info("Job completed successfully, cleaning up job and secret")
2✔
197
                        if err := r.cleanupResources(ctx, log, res); err != nil {
2✔
NEW
198
                                log.Error(err, "failed to cleanup resources after successful job completion")
×
NEW
199
                                // Don't fail reconciliation, job is already successful
×
NEW
200
                        }
×
201
                        return ctrlResult, nil
2✔
202
                }
203

204
                // Check if job is still running
205
                if !isJobComplete(job) {
46✔
206
                        log.V(1).Info("Job is still running, requeue after 5 seconds")
23✔
207
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
23✔
208
                }
23✔
209
        }
210

NEW
211
        return ctrlResult, nil
×
212
}
213

214
// SetupWithManager sets up the controller with the Manager.
215
func (r *ReleaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
1✔
216
        return ctrl.NewControllerManagedBy(mgr).
1✔
217
                For(&solarv1alpha1.Release{}).
1✔
218
                Owns(&batchv1.Job{}).
1✔
219
                Owns(&corev1.Secret{}).
1✔
220
                Complete(r)
1✔
221
}
1✔
222

223
// createOrUpdateConfigSecret creates or updates a secret containing the renderer configuration
224
func (r *ReleaseReconciler) createOrUpdateConfigSecret(ctx context.Context, log logr.Logger, rel *solarv1alpha1.Release) (*corev1.Secret, error) {
25✔
225
        // Build the renderer configuration
25✔
226
        cfg := renderer.RendererConfig{
25✔
227
                Type: "release",
25✔
228
                ReleaseConfig: renderer.ReleaseConfig{
25✔
229
                        Chart: renderer.ChartConfig{
25✔
230
                                Name:        rel.Name,
25✔
231
                                Description: fmt.Sprintf("Release of %s", rel.Spec.ComponentVersionRef.Name),
25✔
232
                                Version:     "1.0.0", // TODO: derive from component version
25✔
233
                                AppVersion:  "1.0.0", // TODO: derive from component version
25✔
234
                        },
25✔
235
                        Input: renderer.ReleaseInput{
25✔
236
                                Component: renderer.ReleaseComponent{}, // TODO: populate from component version
25✔
237
                                Helm:      renderer.ResourceAccess{},   // TODO: populate from component version
25✔
238
                                KRO:       renderer.ResourceAccess{},   // TODO: populate from component version
25✔
239
                                Resources: make(map[string]renderer.ResourceAccess),
25✔
240
                        },
25✔
241
                        Values: rel.Spec.Values.Raw,
25✔
242
                },
25✔
243
                PushOptions: r.PushOptions,
25✔
244
        }
25✔
245

25✔
246
        // Marshal config to JSON
25✔
247
        configJSON, err := json.Marshal(cfg)
25✔
248
        if err != nil {
25✔
NEW
249
                return nil, fmt.Errorf("failed to marshal renderer config: %w", err)
×
NEW
250
        }
×
251

252
        // Create or get secret name
253
        secretName := fmt.Sprintf("%s-config", rel.Name)
25✔
254

25✔
255
        // Create or update the secret
25✔
256
        secret := &corev1.Secret{
25✔
257
                ObjectMeta: metav1.ObjectMeta{
25✔
258
                        Name:      secretName,
25✔
259
                        Namespace: rel.Namespace,
25✔
260
                        OwnerReferences: []metav1.OwnerReference{
25✔
261
                                *metav1.NewControllerRef(rel, solarv1alpha1.SchemeGroupVersion.WithKind("Release")),
25✔
262
                        },
25✔
263
                        Annotations: map[string]string{
25✔
264
                                AnnotationSecretName: secretName,
25✔
265
                        },
25✔
266
                },
25✔
267
                Type: corev1.SecretTypeOpaque,
25✔
268
                Data: map[string][]byte{
25✔
269
                        "config.json": configJSON,
25✔
270
                },
25✔
271
        }
25✔
272

25✔
273
        // Try to get existing secret
25✔
274
        existingSecret := &corev1.Secret{}
25✔
275
        err = r.Get(ctx, client.ObjectKey{Name: secretName, Namespace: rel.Namespace}, existingSecret)
25✔
276
        if err != nil && !apierrors.IsNotFound(err) {
25✔
NEW
277
                return nil, fmt.Errorf("failed to get existing secret: %w", err)
×
NEW
278
        }
×
279

280
        if err == nil {
42✔
281
                // Update existing secret
17✔
282
                existingSecret.Data = secret.Data
17✔
283
                if err := r.Update(ctx, existingSecret); err != nil {
17✔
NEW
284
                        return nil, fmt.Errorf("failed to update secret: %w", err)
×
NEW
285
                }
×
286
                return existingSecret, nil
17✔
287
        }
288

289
        // Create new secret
290
        if err := r.Create(ctx, secret); err != nil {
8✔
NEW
291
                return nil, fmt.Errorf("failed to create secret: %w", err)
×
NEW
292
        }
×
293

294
        return secret, nil
8✔
295
}
296

297
// getOrCreateJob creates or gets the renderer job
298
func (r *ReleaseReconciler) getOrCreateJob(ctx context.Context, log logr.Logger, rel *solarv1alpha1.Release, configSecret *corev1.Secret) (*batchv1.Job, error) {
25✔
299
        jobName := fmt.Sprintf("%s-renderer", rel.Name)
25✔
300

25✔
301
        // Try to get existing job
25✔
302
        job := &batchv1.Job{}
25✔
303
        err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: rel.Namespace}, job)
25✔
304
        if err != nil && !apierrors.IsNotFound(err) {
25✔
NEW
305
                return nil, fmt.Errorf("failed to get existing job: %w", err)
×
NEW
306
        }
×
307

308
        if err == nil {
42✔
309
                // Job already exists
17✔
310
                log.V(1).Info("Job already exists", "job", jobName)
17✔
311
                return job, nil
17✔
312
        }
17✔
313

314
        // Create new job
315
        backoffLimit := int32(3)
8✔
316
        ttlSecondsAfterFinished := int32(3600) // Clean up after 1 hour
8✔
317

8✔
318
        job = &batchv1.Job{
8✔
319
                ObjectMeta: metav1.ObjectMeta{
8✔
320
                        Name:      jobName,
8✔
321
                        Namespace: rel.Namespace,
8✔
322
                        OwnerReferences: []metav1.OwnerReference{
8✔
323
                                *metav1.NewControllerRef(rel, solarv1alpha1.SchemeGroupVersion.WithKind("Release")),
8✔
324
                        },
8✔
325
                        Annotations: map[string]string{
8✔
326
                                AnnotationJobName: jobName,
8✔
327
                        },
8✔
328
                },
8✔
329
                Spec: batchv1.JobSpec{
8✔
330
                        BackoffLimit:            &backoffLimit,
8✔
331
                        TTLSecondsAfterFinished: &ttlSecondsAfterFinished,
8✔
332
                        Template: corev1.PodTemplateSpec{
8✔
333
                                Spec: corev1.PodSpec{
8✔
334
                                        RestartPolicy: corev1.RestartPolicyNever,
8✔
335
                                        Containers: []corev1.Container{
8✔
336
                                                {
8✔
337
                                                        Name:    "renderer",
8✔
338
                                                        Image:   r.RendererImage,
8✔
339
                                                        Command: []string{r.RendererCommand},
8✔
340
                                                        Args:    append(r.RendererArgs, "/etc/renderer/config.json"),
8✔
341
                                                        Env: []corev1.EnvVar{
8✔
342
                                                                {
8✔
343
                                                                        Name: "POD_NAMESPACE",
8✔
344
                                                                        ValueFrom: &corev1.EnvVarSource{
8✔
345
                                                                                FieldRef: &corev1.ObjectFieldSelector{
8✔
346
                                                                                        FieldPath: "metadata.namespace",
8✔
347
                                                                                },
8✔
348
                                                                        },
8✔
349
                                                                },
8✔
350
                                                                {
8✔
351
                                                                        Name: "POD_NAME",
8✔
352
                                                                        ValueFrom: &corev1.EnvVarSource{
8✔
353
                                                                                FieldRef: &corev1.ObjectFieldSelector{
8✔
354
                                                                                        FieldPath: "metadata.name",
8✔
355
                                                                                },
8✔
356
                                                                        },
8✔
357
                                                                },
8✔
358
                                                        },
8✔
359
                                                        VolumeMounts: []corev1.VolumeMount{
8✔
360
                                                                {
8✔
361
                                                                        Name:      "config",
8✔
362
                                                                        MountPath: "/etc/renderer",
8✔
363
                                                                        ReadOnly:  true,
8✔
364
                                                                },
8✔
365
                                                        },
8✔
366
                                                },
8✔
367
                                        },
8✔
368
                                        Volumes: []corev1.Volume{
8✔
369
                                                {
8✔
370
                                                        Name: "config",
8✔
371
                                                        VolumeSource: corev1.VolumeSource{
8✔
372
                                                                Secret: &corev1.SecretVolumeSource{
8✔
373
                                                                        SecretName: configSecret.Name,
8✔
374
                                                                        Items: []corev1.KeyToPath{
8✔
375
                                                                                {
8✔
376
                                                                                        Key:  "config.json",
8✔
377
                                                                                        Path: "config.json",
8✔
378
                                                                                },
8✔
379
                                                                        },
8✔
380
                                                                },
8✔
381
                                                        },
8✔
382
                                                },
8✔
383
                                        },
8✔
384
                                },
8✔
385
                        },
8✔
386
                },
8✔
387
        }
8✔
388

8✔
389
        if err := r.Create(ctx, job); err != nil {
8✔
NEW
390
                return nil, fmt.Errorf("failed to create job: %w", err)
×
NEW
391
        }
×
392

393
        log.V(1).Info("Created new job", "job", jobName)
8✔
394
        r.Recorder.Event(rel, corev1.EventTypeNormal, "JobScheduled", fmt.Sprintf("Scheduled renderer job: %s", jobName))
8✔
395

8✔
396
        return job, nil
8✔
397
}
398

399
// updateReleaseStatusFromJob updates the Release status based on job status
400
func (r *ReleaseReconciler) updateReleaseStatusFromJob(ctx context.Context, log logr.Logger, rel *solarv1alpha1.Release, job *batchv1.Job) error {
25✔
401
        if job.Status.Succeeded > 0 {
27✔
402
                apimeta.SetStatusCondition(&rel.Status.Conditions, metav1.Condition{
2✔
403
                        Type:               ConditionTypeJobSucceeded,
2✔
404
                        Status:             metav1.ConditionTrue,
2✔
405
                        ObservedGeneration: rel.Generation,
2✔
406
                        Reason:             "JobSucceeded",
2✔
407
                        Message:            fmt.Sprintf("Renderer job completed successfully at %v", job.Status.CompletionTime),
2✔
408
                })
2✔
409
                r.Recorder.Event(rel, corev1.EventTypeNormal, "JobSucceeded", "Renderer job completed successfully")
2✔
410
                return nil
2✔
411
        }
2✔
412

413
        if job.Status.Failed > 0 {
25✔
414
                apimeta.SetStatusCondition(&rel.Status.Conditions, metav1.Condition{
2✔
415
                        Type:               ConditionTypeJobFailed,
2✔
416
                        Status:             metav1.ConditionTrue,
2✔
417
                        ObservedGeneration: rel.Generation,
2✔
418
                        Reason:             "JobFailed",
2✔
419
                        Message:            "Renderer job failed",
2✔
420
                })
2✔
421
                r.Recorder.Event(rel, corev1.EventTypeWarning, "JobFailed", "Renderer job failed")
2✔
422
                return nil
2✔
423
        }
2✔
424

425
        // Job is still running
426
        apimeta.SetStatusCondition(&rel.Status.Conditions, metav1.Condition{
21✔
427
                Type:               ConditionTypeJobScheduled,
21✔
428
                Status:             metav1.ConditionTrue,
21✔
429
                ObservedGeneration: rel.Generation,
21✔
430
                Reason:             "JobScheduled",
21✔
431
                Message:            fmt.Sprintf("Renderer job is running (active: %d, succeeded: %d, failed: %d)", job.Status.Active, job.Status.Succeeded, job.Status.Failed),
21✔
432
        })
21✔
433

21✔
434
        return nil
21✔
435
}
436

437
// cleanupResources deletes the job and secret associated with a Release
438
func (r *ReleaseReconciler) cleanupResources(ctx context.Context, log logr.Logger, rel *solarv1alpha1.Release) error {
3✔
439
        jobName := fmt.Sprintf("%s-renderer", rel.Name)
3✔
440
        job := &batchv1.Job{}
3✔
441
        if err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: rel.Namespace}, job); err == nil {
6✔
442
                if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil {
3✔
NEW
443
                        log.Error(err, "failed to delete job", "job", jobName)
×
NEW
444
                        return fmt.Errorf("failed to delete job: %w", err)
×
NEW
445
                }
×
446
                log.V(1).Info("Deleted job", "job", jobName)
3✔
NEW
447
        } else if !apierrors.IsNotFound(err) {
×
NEW
448
                return fmt.Errorf("failed to get job: %w", err)
×
NEW
449
        }
×
450

451
        secretName := fmt.Sprintf("%s-config", rel.Name)
3✔
452
        secret := &corev1.Secret{}
3✔
453
        if err := r.Get(ctx, client.ObjectKey{Name: secretName, Namespace: rel.Namespace}, secret); err == nil {
6✔
454
                if err := r.Delete(ctx, secret); err != nil {
3✔
NEW
455
                        log.Error(err, "failed to delete secret", "secret", secretName)
×
NEW
456
                        return fmt.Errorf("failed to delete secret: %w", err)
×
NEW
457
                }
×
458
                log.V(1).Info("Deleted secret", "secret", secretName)
3✔
NEW
459
        } else if !apierrors.IsNotFound(err) {
×
NEW
460
                return fmt.Errorf("failed to get secret: %w", err)
×
NEW
461
        }
×
462

463
        return nil
3✔
464
}
465

466
// isJobComplete checks if a job has completed (either succeeded or failed)
467
func isJobComplete(job *batchv1.Job) bool {
48✔
468
        return job.Status.CompletionTime != nil
48✔
469
}
48✔
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