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

kubevirt / containerized-data-importer / #4794

14 Jul 2024 06:12PM UTC coverage: 58.983% (+0.01%) from 58.972%
#4794

push

travis-ci

web-flow
update to k8s 1.30 libs and controller-runtime 0.18.4 (#3336)

* make deps-update

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* ReourceRequirements -> VolumeResourceRequirements

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* fix calls to controller.Watch()

controller-runtime changed the API!

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* Fix errors with actual openshift/library-go lib

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* make all works now and everything compiles

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* fix "make update-codegen" because generate_groups.sh deprecated

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* run "make generate"

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

* fix transfer unittest because of change to controller-runtime

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

---------

Signed-off-by: Michael Henriksen <mhenriks@redhat.com>

6 of 238 new or added lines in 24 files covered. (2.52%)

10 existing lines in 4 files now uncovered.

16454 of 27896 relevant lines covered (58.98%)

0.65 hits per line

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

50.29
/pkg/operator/controller/callbacks.go
1
/*
2
Copyright 2018 The CDI Authors.
3

4
Licensed under the Apache License, Version 2.0 (the "License");
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    http://www.apache.org/licenses/LICENSE-2.0
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package controller
18

19
import (
20
        "context"
21
        "fmt"
22
        "reflect"
23

24
        "github.com/go-logr/logr"
25

26
        admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
27
        appsv1 "k8s.io/api/apps/v1"
28
        corev1 "k8s.io/api/core/v1"
29
        extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
30
        "k8s.io/apimachinery/pkg/api/errors"
31
        "k8s.io/apimachinery/pkg/api/meta"
32
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33
        "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
34
        "k8s.io/apimachinery/pkg/labels"
35
        "k8s.io/apimachinery/pkg/runtime"
36
        "k8s.io/apimachinery/pkg/runtime/schema"
37

38
        "sigs.k8s.io/controller-runtime/pkg/client"
39
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
40

41
        cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1"
42
        "kubevirt.io/containerized-data-importer/pkg/common"
43
        cdicontroller "kubevirt.io/containerized-data-importer/pkg/controller"
44
        cc "kubevirt.io/containerized-data-importer/pkg/controller/common"
45
        featuregates "kubevirt.io/containerized-data-importer/pkg/feature-gates"
46
        "kubevirt.io/containerized-data-importer/pkg/operator/resources/cluster"
47
        "kubevirt.io/containerized-data-importer/pkg/operator/resources/utils"
48
        sdk "kubevirt.io/controller-lifecycle-operator-sdk/pkg/sdk"
49
        "kubevirt.io/controller-lifecycle-operator-sdk/pkg/sdk/callbacks"
50
)
51

52
func addReconcileCallbacks(r *ReconcileCDI) {
1✔
53
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileDeleteControllerDeployment)
1✔
54
        r.reconciler.AddCallback(&corev1.ServiceAccount{}, reconcileSCC)
1✔
55
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileCreateRoute)
1✔
56
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileCreatePrometheusInfra)
1✔
57
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileRemainingRelationshipLabels)
1✔
58
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileDeleteDeprecatedResources)
1✔
59
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcileCDICRD)
1✔
60
        r.reconciler.AddCallback(&appsv1.Deployment{}, reconcilePvcMutatingWebhook)
1✔
61
        r.reconciler.AddCallback(&extv1.CustomResourceDefinition{}, reconcileSetConfigAuthority)
1✔
62
        r.reconciler.AddCallback(&extv1.CustomResourceDefinition{}, reconcileHandleOldVersion)
1✔
63
}
1✔
64

65
func isControllerDeployment(d *appsv1.Deployment) bool {
1✔
66
        return d.Name == "cdi-deployment"
1✔
67
}
1✔
68

69
func reconcileDeleteControllerDeployment(args *callbacks.ReconcileCallbackArgs) error {
1✔
70
        switch args.State {
1✔
71
        case callbacks.ReconcileStatePostDelete, callbacks.ReconcileStateOperatorDelete:
1✔
72
        default:
1✔
73
                return nil
1✔
74
        }
75

76
        var deployment *appsv1.Deployment
1✔
77
        if args.DesiredObject != nil {
2✔
78
                deployment = args.DesiredObject.(*appsv1.Deployment)
1✔
79
        } else if args.CurrentObject != nil {
3✔
80
                deployment = args.CurrentObject.(*appsv1.Deployment)
1✔
81
        } else {
1✔
82
                args.Logger.Info("Received callback with no desired/current object")
×
83
                return nil
×
84
        }
×
85

86
        if !isControllerDeployment(deployment) {
2✔
87
                return nil
1✔
88
        }
1✔
89

90
        args.Logger.Info("Deleting CDI deployment and all import/upload/clone pods/services")
1✔
91
        err := args.Client.Delete(context.TODO(), deployment, &client.DeleteOptions{
1✔
92
                PropagationPolicy: &[]metav1.DeletionPropagation{metav1.DeletePropagationForeground}[0],
1✔
93
        })
1✔
94
        cr := args.Resource.(runtime.Object)
1✔
95
        if err != nil && !errors.IsNotFound(err) {
1✔
96
                args.Logger.Error(err, "Error deleting cdi controller deployment")
×
97
                args.Recorder.Event(cr, corev1.EventTypeWarning, deleteResourceFailed, fmt.Sprintf("Failed to delete deployment %s, %v", deployment.Name, err))
×
98
                return err
×
99
        }
×
100
        args.Recorder.Event(cr, corev1.EventTypeNormal, deleteResourceSuccess, fmt.Sprintf("Deleted deployment %s successfully", deployment.Name))
1✔
101

1✔
102
        if err = deleteWorkerResources(args.Logger, args.Client); err != nil {
1✔
103
                args.Logger.Error(err, "Error deleting worker resources")
×
104
                args.Recorder.Event(cr, corev1.EventTypeWarning, deleteResourceFailed, fmt.Sprintf("Failed to deleted worker resources %v", err))
×
105
                return err
×
106
        }
×
107
        args.Recorder.Event(cr, corev1.EventTypeNormal, deleteResourceSuccess, "Deleted worker resources successfully")
1✔
108

1✔
109
        return nil
1✔
110
}
111

112
func reconcileCreateRoute(args *callbacks.ReconcileCallbackArgs) error {
1✔
113
        if args.State != callbacks.ReconcileStatePostRead {
2✔
114
                return nil
1✔
115
        }
1✔
116

117
        deployment := args.CurrentObject.(*appsv1.Deployment)
1✔
118
        if !isControllerDeployment(deployment) || !sdk.CheckDeploymentReady(deployment) {
2✔
119
                return nil
1✔
120
        }
1✔
121

122
        cr := args.Resource.(runtime.Object)
1✔
123
        if err := ensureUploadProxyRouteExists(context.TODO(), args.Logger, args.Client, args.Scheme, deployment); err != nil {
1✔
124
                args.Recorder.Event(cr, corev1.EventTypeWarning, createResourceFailed, fmt.Sprintf("Failed to ensure upload proxy route exists, %v", err))
×
125
                return err
×
126
        }
×
127
        args.Recorder.Event(cr, corev1.EventTypeNormal, createResourceSuccess, "Successfully ensured upload proxy route exists")
1✔
128

1✔
129
        return nil
1✔
130
}
131

132
func reconcileSCC(args *callbacks.ReconcileCallbackArgs) error {
1✔
133
        switch args.State {
1✔
134
        case callbacks.ReconcileStatePreCreate, callbacks.ReconcileStatePostRead:
1✔
135
        default:
1✔
136
                return nil
1✔
137
        }
138

139
        sa := args.DesiredObject.(*corev1.ServiceAccount)
1✔
140
        if sa.Name != common.ControllerServiceAccountName {
2✔
141
                return nil
1✔
142
        }
1✔
143

144
        cr := args.Resource.(runtime.Object)
1✔
145
        if err := ensureSCCExists(context.TODO(), args.Logger, args.Client, args.Namespace, common.ControllerServiceAccountName, common.CronJobServiceAccountName); err != nil {
1✔
146
                args.Recorder.Event(cr, corev1.EventTypeWarning, createResourceFailed, fmt.Sprintf("Failed to ensure SecurityContextConstraint exists, %v", err))
×
147
                return err
×
148
        }
×
149
        args.Recorder.Event(cr, corev1.EventTypeNormal, createResourceSuccess, "Successfully ensured SecurityContextConstraint exists")
1✔
150

1✔
151
        return nil
1✔
152
}
153

154
func reconcileCreatePrometheusInfra(args *callbacks.ReconcileCallbackArgs) error {
1✔
155
        if args.State != callbacks.ReconcileStatePostRead {
2✔
156
                return nil
1✔
157
        }
1✔
158

159
        deployment := args.CurrentObject.(*appsv1.Deployment)
1✔
160
        // we don't check sdk.CheckDeploymentReady(deployment) since we want Prometheus to cover NotReady state as well
1✔
161
        if !isControllerDeployment(deployment) {
2✔
162
                return nil
1✔
163
        }
1✔
164

165
        cr := args.Resource.(runtime.Object)
1✔
166
        namespace := deployment.GetNamespace()
1✔
167
        if namespace == "" {
1✔
168
                return fmt.Errorf("cluster scoped owner not supported")
×
169
        }
×
170

171
        if deployed, err := isPrometheusDeployed(args.Logger, args.Client, namespace); err != nil {
1✔
172
                return err
×
173
        } else if !deployed {
1✔
174
                return nil
×
175
        }
×
176
        if err := ensurePrometheusResourcesExist(context.TODO(), args.Client, args.Scheme, deployment); err != nil {
1✔
177
                args.Recorder.Event(cr, corev1.EventTypeWarning, createResourceFailed, fmt.Sprintf("Failed to ensure prometheus resources exists, %v", err))
×
178
                return err
×
179
        }
×
180

181
        return nil
1✔
182
}
183

184
func deleteWorkerResources(l logr.Logger, c client.Client) error {
1✔
185
        listTypes := []client.ObjectList{&corev1.PodList{}, &corev1.ServiceList{}}
1✔
186

1✔
187
        ls, err := labels.Parse(fmt.Sprintf("cdi.kubevirt.io in (%s, %s, %s)",
1✔
188
                common.ImporterPodName, common.UploadServerCDILabel, common.ClonerSourcePodName))
1✔
189
        if err != nil {
1✔
190
                return err
×
191
        }
×
192

193
        for _, lt := range listTypes {
2✔
194
                lo := &client.ListOptions{
1✔
195
                        LabelSelector: ls,
1✔
196
                }
1✔
197

1✔
198
                l.V(1).Info("Deleting worker resources", "type", reflect.TypeOf(lt).Elem().Name())
1✔
199

1✔
200
                if err := cc.BulkDeleteResources(context.TODO(), c, lt, lo); err != nil {
1✔
201
                        return err
×
202
                }
×
203
        }
204

205
        return nil
1✔
206
}
207

208
func reconcileSetConfigAuthority(args *callbacks.ReconcileCallbackArgs) error {
1✔
209
        if args.State != callbacks.ReconcileStatePostRead {
2✔
210
                return nil
1✔
211
        }
1✔
212

213
        crd := args.CurrentObject.(*extv1.CustomResourceDefinition)
1✔
214
        if crd.Name != "cdiconfigs.cdi.kubevirt.io" {
2✔
215
                return nil
1✔
216
        }
1✔
217

218
        cdi, ok := args.Resource.(*cdiv1.CDI)
1✔
219
        if !ok {
1✔
220
                return nil
×
221
        }
×
222

223
        if _, ok = cdi.Annotations[cdicontroller.AnnConfigAuthority]; ok {
2✔
224
                return nil
1✔
225
        }
1✔
226

227
        if cdi.Spec.Config == nil {
2✔
228
                cl := &cdiv1.CDIConfigList{}
1✔
229
                err := args.Client.List(context.TODO(), cl)
1✔
230
                if err != nil {
1✔
231
                        if meta.IsNoMatchError(err) {
×
232
                                return nil
×
233
                        }
×
234

235
                        return err
×
236
                }
237

238
                if len(cl.Items) != 1 {
2✔
239
                        return nil
1✔
240
                }
1✔
241

242
                cs := cl.Items[0].Spec.DeepCopy()
1✔
243
                if !reflect.DeepEqual(cs, &cdiv1.CDIConfigSpec{}) {
2✔
244
                        cdi.Spec.Config = cs
1✔
245
                }
1✔
246
        }
247

248
        if cdi.Annotations == nil {
2✔
249
                cdi.Annotations = map[string]string{}
1✔
250
        }
1✔
251
        cdi.Annotations[cdicontroller.AnnConfigAuthority] = ""
1✔
252

1✔
253
        return args.Client.Update(context.TODO(), cdi)
1✔
254
}
255

256
func getSpecVersion(version string, crd *extv1.CustomResourceDefinition) *extv1.CustomResourceDefinitionVersion {
1✔
257
        for _, v := range crd.Spec.Versions {
2✔
258
                if v.Name == version {
2✔
259
                        return &v
1✔
260
                }
1✔
261
        }
262
        return nil
1✔
263
}
264

265
func rewriteOldObjects(args *callbacks.ReconcileCallbackArgs, version string, crd *extv1.CustomResourceDefinition) error {
×
266
        args.Logger.Info("Rewriting old objects")
×
267
        kind := crd.Spec.Names.Kind
×
268
        gvk := schema.GroupVersionKind{
×
269
                Group:   crd.Spec.Group,
×
270
                Version: version,
×
271
                Kind:    kind,
×
272
        }
×
273
        ul := &unstructured.UnstructuredList{}
×
274
        ul.SetGroupVersionKind(gvk)
×
275
        err := args.Client.List(context.TODO(), ul, &client.ListOptions{})
×
276
        if err != nil {
×
277
                return err
×
278
        }
×
279
        for _, item := range ul.Items {
×
280
                nn := client.ObjectKey{Namespace: item.GetNamespace(), Name: item.GetName()}
×
281
                u := &unstructured.Unstructured{}
×
282
                u.SetGroupVersionKind(item.GetObjectKind().GroupVersionKind())
×
283
                err = args.Client.Get(context.TODO(), nn, u)
×
284
                if err != nil {
×
285
                        return err
×
286
                }
×
287
                err = args.Client.Update(context.TODO(), u)
×
288
                if err != nil {
×
289
                        return err
×
290
                }
×
291
        }
292
        return nil
×
293
}
294

295
func removeStoredVersion(args *callbacks.ReconcileCallbackArgs, desiredVersion string, crd *extv1.CustomResourceDefinition) error {
×
296
        args.Logger.Info("Removing stored version")
×
297
        crd.Status.StoredVersions = []string{desiredVersion}
×
298
        return args.Client.Status().Update(context.TODO(), crd)
×
299
}
×
300

301
// Handle upgrade from clusters that had v1alpha1 as a storage version
302
// and remove it from all CRDs managed by us
303
func reconcileHandleOldVersion(args *callbacks.ReconcileCallbackArgs) error {
1✔
304
        if args.State != callbacks.ReconcileStatePostRead {
2✔
305
                return nil
1✔
306
        }
1✔
307
        currentCrd := args.CurrentObject.(*extv1.CustomResourceDefinition)
1✔
308
        desiredCrd := args.DesiredObject.(*extv1.CustomResourceDefinition)
1✔
309
        desiredVersion := newestVersion(desiredCrd)
1✔
310

1✔
311
        if olderVersionsExist(desiredVersion, currentCrd) {
1✔
312
                restoreOlderVersions(currentCrd, desiredCrd)
×
313
                if !desiredIsStorage(desiredVersion, currentCrd) {
×
314
                        // Let kubernetes add it
×
315
                        return nil
×
316
                }
×
317
                if err := rewriteOldObjects(args, desiredVersion, currentCrd); err != nil {
×
318
                        return err
×
319
                }
×
320
                if err := removeStoredVersion(args, desiredVersion, currentCrd); err != nil {
×
321
                        return err
×
322
                }
×
323
        }
324
        return nil
1✔
325
}
326

327
func olderVersionsExist(desiredVersion string, crd *extv1.CustomResourceDefinition) bool {
1✔
328
        for _, version := range crd.Status.StoredVersions {
1✔
UNCOV
329
                if version != desiredVersion {
×
330
                        return true
×
331
                }
×
332
        }
333
        return false
1✔
334
}
335

336
func desiredIsStorage(desiredVersion string, crd *extv1.CustomResourceDefinition) bool {
×
337
        specVersion := getSpecVersion(desiredVersion, crd)
×
338
        return specVersion != nil && specVersion.Storage
×
339
}
×
340

341
func newestVersion(crd *extv1.CustomResourceDefinition) string {
1✔
342
        orderedVersions := []string{"v1", "v1beta1", "v1alpha1"}
1✔
343
        for _, version := range orderedVersions {
2✔
344
                specVersion := getSpecVersion(version, crd)
1✔
345
                if specVersion != nil {
2✔
346
                        return version
1✔
347
                }
1✔
348
        }
349
        return ""
×
350
}
351

352
// Merge both old and new versions into new CRD, so we have both in the desiredCrd object
353
func restoreOlderVersions(currentCrd, desiredCrd *extv1.CustomResourceDefinition) *extv1.CustomResourceDefinition {
×
354
        for _, version := range currentCrd.Status.StoredVersions {
×
355
                specVersion := getSpecVersion(version, desiredCrd)
×
356
                if specVersion == nil {
×
357
                        // Not available in desired CRD, restore from current CRD
×
358
                        specVersion := getSpecVersion(version, currentCrd)
×
359
                        // We are only allowed one storage version
×
360
                        // The desired CRD already has one
×
361
                        specVersion.Storage = false
×
362

×
363
                        desiredCrd.Spec.Versions = append(desiredCrd.Spec.Versions, *specVersion)
×
364
                }
×
365
        }
366
        return desiredCrd
×
367
}
368

369
func reconcilePvcMutatingWebhook(args *callbacks.ReconcileCallbackArgs) error {
1✔
370
        if args.State != callbacks.ReconcileStatePostRead {
2✔
371
                return nil
1✔
372
        }
1✔
373

374
        deployment, ok := args.DesiredObject.(*appsv1.Deployment)
1✔
375
        if !ok || deployment.Name != common.CDIApiServerResourceName {
2✔
376
                return nil
1✔
377
        }
1✔
378

379
        enabled, err := featuregates.IsWebhookPvcRenderingEnabled(args.Client)
1✔
380
        if err != nil {
2✔
381
                return cc.IgnoreNotFound(err)
1✔
382
        }
1✔
383

384
        whc := &admissionregistrationv1.MutatingWebhookConfiguration{}
1✔
385
        key := client.ObjectKey{Name: "cdi-api-pvc-mutate"}
1✔
386
        err = args.Client.Get(context.TODO(), key, whc)
1✔
387
        if err != nil && !errors.IsNotFound(err) {
1✔
388
                return err
×
389
        }
×
390

391
        exists := err == nil
1✔
392
        if !enabled {
2✔
393
                if !exists {
2✔
394
                        return nil
1✔
395
                }
1✔
396
                err = args.Client.Delete(context.TODO(), whc)
×
397
                return client.IgnoreNotFound(err)
×
398
        }
399

400
        if !exists {
×
401
                if err := initPvcMutatingWebhook(whc, args); err != nil {
×
402
                        return err
×
403
                }
×
404
                return args.Client.Create(context.TODO(), whc)
×
405
        }
406

407
        whcCopy := whc.DeepCopy()
×
408
        if err := initPvcMutatingWebhook(whc, args); err != nil {
×
409
                return err
×
410
        }
×
411
        if !reflect.DeepEqual(whc, whcCopy) {
×
412
                return args.Client.Update(context.TODO(), whc)
×
413
        }
×
414

415
        return nil
×
416
}
417

418
func initPvcMutatingWebhook(whc *admissionregistrationv1.MutatingWebhookConfiguration, args *callbacks.ReconcileCallbackArgs) error {
×
419
        path := "/pvc-mutate"
×
420
        defaultServicePort := int32(443)
×
421
        allScopes := admissionregistrationv1.AllScopes
×
422
        exactPolicy := admissionregistrationv1.Exact
×
423
        failurePolicy := admissionregistrationv1.Fail
×
424
        defaultTimeoutSeconds := int32(10)
×
425
        reinvocationNever := admissionregistrationv1.NeverReinvocationPolicy
×
426
        sideEffect := admissionregistrationv1.SideEffectClassNone
×
427
        bundle := cluster.GetAPIServerCABundle(args.Namespace, args.Client, args.Logger)
×
428

×
429
        whc.Name = "cdi-api-pvc-mutate"
×
430
        whc.Labels = map[string]string{utils.CDILabel: cluster.APIServerServiceName}
×
431
        whc.Webhooks = []admissionregistrationv1.MutatingWebhook{
×
432
                {
×
433
                        Name: "pvc-mutate.cdi.kubevirt.io",
×
434
                        Rules: []admissionregistrationv1.RuleWithOperations{{
×
435
                                Operations: []admissionregistrationv1.OperationType{
×
436
                                        admissionregistrationv1.Create,
×
437
                                },
×
438
                                Rule: admissionregistrationv1.Rule{
×
439
                                        APIGroups:   []string{corev1.SchemeGroupVersion.Group},
×
440
                                        APIVersions: []string{corev1.SchemeGroupVersion.Version},
×
441
                                        Resources:   []string{"persistentvolumeclaims"},
×
442
                                        Scope:       &allScopes,
×
443
                                },
×
444
                        }},
×
445
                        ClientConfig: admissionregistrationv1.WebhookClientConfig{
×
446
                                Service: &admissionregistrationv1.ServiceReference{
×
447
                                        Namespace: args.Namespace,
×
448
                                        Name:      cluster.APIServerServiceName,
×
449
                                        Path:      &path,
×
450
                                        Port:      &defaultServicePort,
×
451
                                },
×
452
                                CABundle: bundle,
×
453
                        },
×
454
                        FailurePolicy:     &failurePolicy,
×
455
                        SideEffects:       &sideEffect,
×
456
                        MatchPolicy:       &exactPolicy,
×
457
                        NamespaceSelector: &metav1.LabelSelector{},
×
458
                        TimeoutSeconds:    &defaultTimeoutSeconds,
×
459
                        AdmissionReviewVersions: []string{
×
460
                                "v1",
×
461
                        },
×
462
                        ObjectSelector: &metav1.LabelSelector{
×
463
                                MatchLabels: map[string]string{
×
464
                                        common.PvcApplyStorageProfileLabel: "true",
×
465
                                },
×
466
                        },
×
467
                        ReinvocationPolicy: &reinvocationNever,
×
468
                },
×
469
        }
×
470

×
471
        cdi, err := cc.GetActiveCDI(context.TODO(), args.Client)
×
472
        if err != nil {
×
473
                return err
×
474
        }
×
475

476
        return controllerutil.SetControllerReference(cdi, whc, args.Scheme)
×
477
}
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