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

kubevirt / kubevirt / 2f1f34cb-c80f-4abc-862e-d763df8e2ecc

06 Mar 2026 08:26AM UTC coverage: 71.278% (-0.06%) from 71.337%
2f1f34cb-c80f-4abc-862e-d763df8e2ecc

push

prow

web-flow
Merge pull request #16930 from Acedus/add-cbt-pull-mode

VEP 25: Introduce incremental backup pull mode support

867 of 1319 new or added lines in 17 files covered. (65.73%)

6 existing lines in 4 files now uncovered.

76896 of 107882 relevant lines covered (71.28%)

499.67 hits per line

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

54.36
/pkg/storage/export/virt-exportserver/exportserver.go
1
/*
2
 * This file is part of the KubeVirt project
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
 * Copyright The KubeVirt Authors.
17
 *
18
 */
19

20
package virtexportserver
21

22
import (
23
        "bytes"
24
        "context"
25
        "crypto/tls"
26
        "crypto/x509"
27
        "encoding/json"
28
        "errors"
29
        goflag "flag"
30
        "fmt"
31
        "io"
32
        golog "log"
33
        "net"
34
        "net/http"
35
        "os"
36
        "os/exec"
37
        "path"
38
        "path/filepath"
39
        "strconv"
40
        "strings"
41
        "sync"
42
        "time"
43

44
        gzip "github.com/klauspost/pgzip"
45
        flag "github.com/spf13/pflag"
46
        "golang.org/x/net/http2"
47
        "google.golang.org/grpc"
48
        "google.golang.org/grpc/credentials/insecure"
49
        "google.golang.org/grpc/keepalive"
50
        corev1 "k8s.io/api/core/v1"
51
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
52
        "k8s.io/apimachinery/pkg/runtime"
53
        "k8s.io/apimachinery/pkg/runtime/schema"
54
        "sigs.k8s.io/yaml"
55

56
        virtv1 "kubevirt.io/api/core/v1"
57
        "kubevirt.io/client-go/log"
58
        cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1"
59

60
        nbdv1 "kubevirt.io/kubevirt/pkg/storage/cbt/nbd/v1"
61

62
        backupv1 "kubevirt.io/api/backup/v1alpha1"
63

64
        "kubevirt.io/kubevirt/pkg/service"
65
        "kubevirt.io/kubevirt/pkg/storage/export/export"
66
        storageutils "kubevirt.io/kubevirt/pkg/storage/utils"
67
)
68

69
const (
70
        authHeader              = "x-kubevirt-export-token"
71
        manifestCmBasePath      = "/manifest_data/"
72
        vmManifestPath          = manifestCmBasePath + "virtualmachine-manifest"
73
        internalLinkPath        = manifestCmBasePath + "internal_host"
74
        internalCaConfigMapPath = manifestCmBasePath + "internal_ca_cm"
75
        externalLinkPath        = manifestCmBasePath + "external_host"
76
        externalCaConfigMapPath = manifestCmBasePath + "external_ca_cm"
77
        exportNamePath          = manifestCmBasePath + "export-name"
78

79
        external = "/external"
80
        internal = "/internal"
81

82
        defaultMapPageSize = 512
83
        tunnelIdleTimeout  = 60 * time.Second
84
)
85

86
var (
87
        excludeMap = map[string]struct{}{
88
                "lost+found": {},
89
        }
90
        h2DummyAddr = &net.TCPAddr{}
91
)
92

93
type TokenGetterFunc func() (string, error)
94

95
type ExportServerConfig struct {
96
        Deadline time.Time
97

98
        ListenAddr string
99

100
        CertFile, KeyFile string
101
        BackupCACert      []byte
102

103
        TokenFile string
104

105
        BackupUID        string
106
        BackupType       string
107
        BackupCheckpoint string
108

109
        Paths *export.ServerPaths
110

111
        // unit testing helpers
112
        ArchiveHandler     func(string) http.Handler
113
        DirHandler         func(string, string) http.Handler
114
        FileHandler        func(string) http.Handler
115
        GzipHandler        func(string) http.Handler
116
        VmHandler          func([]export.VolumeInfo, func() (string, error), func() (*corev1.ConfigMap, error)) http.Handler
117
        TokenSecretHandler func(TokenGetterFunc) http.Handler
118

119
        PermissionChecker func(string) bool
120

121
        TokenGetter TokenGetterFunc
122
}
123

124
type execReader struct {
125
        cmd    *exec.Cmd
126
        stdout io.ReadCloser
127
        stderr io.ReadCloser
128
}
129

130
type exportServer struct {
131
        ExportServerConfig
132
        handler http.Handler
133

134
        nbdClient nbdv1.NBDClient
135
        nbdMu     sync.RWMutex
136
}
137

138
func (er *execReader) Read(p []byte) (int, error) {
×
139
        n, err := er.stdout.Read(p)
×
140
        if err == io.EOF {
×
141
                if err2 := er.cmd.Wait(); err2 != nil {
×
142
                        errBytes, _ := io.ReadAll(er.stderr)
×
143
                        log.Log.Reason(err2).Errorf("Subprocess did not execute successfully, result is: %q\n%s", er.cmd.ProcessState.ExitCode(), string(errBytes))
×
144
                        return n, err2
×
145
                }
×
146
        }
147
        return n, err
×
148
}
149

150
func (er *execReader) Close() error {
×
151
        return er.stdout.Close()
×
152
}
×
153

154
func (s *exportServer) initHandler() {
24✔
155
        mux := http.NewServeMux()
24✔
156
        for _, vi := range s.Paths.Volumes {
40✔
157
                if hasPermissions := s.PermissionChecker(vi.Path); !hasPermissions {
16✔
158
                        golog.Fatalf("unable to manipulate %s's contents, exiting", vi.Path)
×
159
                }
×
160
                for path, handler := range s.getHandlerMap(vi) {
32✔
161
                        log.Log.Infof("Handling path %s\n", path)
16✔
162
                        mux.Handle(path, tokenChecker(s.TokenGetter, handler))
16✔
163
                }
16✔
164
        }
165
        for _, bi := range s.Paths.Backups {
24✔
NEW
166
                log.Log.Infof("Handling backup path %s (Map) and %s (Data)\n", bi.MapURI, bi.DataURI)
×
NEW
167
                mux.Handle(bi.MapURI, tokenChecker(s.TokenGetter, s.backupMapHandler(bi.Path)))
×
NEW
168
                mux.Handle(bi.DataURI, tokenChecker(s.TokenGetter, s.backupDataHandler(bi.Path)))
×
NEW
169
        }
×
170
        if s.Paths.VMURI != "" {
32✔
171
                mux.Handle(filepath.Join(internal, s.Paths.VMURI), tokenChecker(s.TokenGetter, s.VmHandler(s.Paths.Volumes, getInternalBasePath, getInternalCAConfigMap)))
8✔
172
                mux.Handle(filepath.Join(external, s.Paths.VMURI), tokenChecker(s.TokenGetter, s.VmHandler(s.Paths.Volumes, getExternalBasePath, getExternalCAConfigMap)))
8✔
173
        }
8✔
174
        if s.Paths.SecretURI != "" {
24✔
175
                mux.Handle(filepath.Join(internal, s.Paths.SecretURI), tokenChecker(s.TokenGetter, s.TokenSecretHandler(s.TokenGetter)))
×
176
                mux.Handle(filepath.Join(external, s.Paths.SecretURI), tokenChecker(s.TokenGetter, s.TokenSecretHandler(s.TokenGetter)))
×
177
        }
×
178
        // Readiness probe
179
        mux.HandleFunc(export.ReadinessPath, s.readyHandler)
24✔
180

24✔
181
        s.handler = mux
24✔
182
}
183

184
func getInternalCAConfigMap() (*corev1.ConfigMap, error) {
×
185
        return getCAConfigMap(internalCaConfigMapPath)
×
186
}
×
187

188
func getExternalCAConfigMap() (*corev1.ConfigMap, error) {
×
189
        return getCAConfigMap(externalCaConfigMapPath)
×
190
}
×
191

192
func (s *exportServer) getHandlerMap(vi export.VolumeInfo) map[string]http.Handler {
16✔
193
        fi, err := os.Stat(vi.Path)
16✔
194
        if err != nil {
16✔
195
                log.Log.Reason(err).Errorf("error statting %s", vi.Path)
×
196
                return nil
×
197
        }
×
198

199
        var result = make(map[string]http.Handler)
16✔
200

16✔
201
        if vi.ArchiveURI != "" {
20✔
202
                result[vi.ArchiveURI] = s.ArchiveHandler(vi.Path)
4✔
203
        }
4✔
204

205
        if vi.DirURI != "" {
20✔
206
                result[vi.DirURI] = s.DirHandler(vi.DirURI, vi.Path)
4✔
207
        }
4✔
208

209
        p := vi.Path
16✔
210
        if fi.IsDir() {
32✔
211
                p = path.Join(p, "disk.img")
16✔
212
        }
16✔
213

214
        if vi.RawURI != "" {
20✔
215
                result[vi.RawURI] = s.FileHandler(p)
4✔
216
        }
4✔
217

218
        if vi.RawGzURI != "" {
20✔
219
                result[vi.RawGzURI] = s.GzipHandler(p)
4✔
220
        }
4✔
221

222
        return result
16✔
223
}
224

225
func (s *exportServer) Run() {
×
226
        s.initHandler()
×
227

×
NEW
228
        srv := s.buildServer()
×
NEW
229

×
NEW
230
        h2Server := &http2.Server{
×
NEW
231
                IdleTimeout: tunnelIdleTimeout,
×
NEW
232
        }
×
NEW
233
        if err := http2.ConfigureServer(srv, h2Server); err != nil {
×
NEW
234
                panic(err)
×
235
        }
236

237
        ch := make(chan error)
×
238

×
239
        go func() {
×
240
                err := srv.ListenAndServeTLS(s.CertFile, s.KeyFile)
×
241
                ch <- err
×
242
        }()
×
243

244
        if !s.Deadline.IsZero() {
×
245
                log.Log.Infof("Deadline set to %s", s.Deadline)
×
246
                select {
×
247
                case err := <-ch:
×
248
                        panic(err)
×
249
                case <-time.After(time.Until(s.Deadline)):
×
250
                        log.Log.Info("Deadline exceeded, shutting down")
×
251
                        srv.Shutdown(context.TODO())
×
252
                }
253
        } else {
×
254
                err := <-ch
×
255
                panic(err)
×
256
        }
257
}
258

259
func (s *exportServer) buildServer() *http.Server {
3✔
260
        tlsConfig := &tls.Config{
3✔
261
                MinVersion: tls.VersionTLS12,
3✔
262
                NextProtos: []string{"h2", "http/1.1"},
3✔
263
        }
3✔
264

3✔
265
        rootHandler := s.handler
3✔
266
        if s.BackupUID != "" {
5✔
267
                clientCAPool := x509.NewCertPool()
2✔
268
                if ok := clientCAPool.AppendCertsFromPEM(s.BackupCACert); !ok {
3✔
269
                        panic("failed to parse Backup CA")
1✔
270
                }
271
                tlsConfig.ClientCAs = clientCAPool
1✔
272
                tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
1✔
273

1✔
274
                rootHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3✔
275
                        if r.Method == http.MethodConnect {
3✔
276
                                s.handleTunnel(w, r)
1✔
277
                                return
1✔
278
                        }
1✔
279
                        s.handler.ServeHTTP(w, r)
1✔
280
                })
281
        }
282

283
        return &http.Server{
2✔
284
                Addr:      s.ListenAddr,
2✔
285
                Handler:   rootHandler,
2✔
286
                TLSConfig: tlsConfig,
2✔
287
        }
2✔
288
}
289

290
func (s *exportServer) AddFlags() {
×
291
        flag.CommandLine.AddGoFlag(goflag.CommandLine.Lookup("v"))
×
292
}
×
293

294
func NewExportServer(config ExportServerConfig) service.Service {
24✔
295
        es := &exportServer{
24✔
296
                ExportServerConfig: config,
24✔
297
        }
24✔
298

24✔
299
        if es.ArchiveHandler == nil {
24✔
300
                es.ArchiveHandler = archiveHandler
×
301
        }
×
302

303
        if es.DirHandler == nil {
24✔
304
                es.DirHandler = dirHandler
×
305
        }
×
306

307
        if es.FileHandler == nil {
24✔
308
                es.FileHandler = fileHandler
×
309
        }
×
310

311
        if es.GzipHandler == nil {
24✔
312
                es.GzipHandler = gzipHandler
×
313
        }
×
314

315
        if es.VmHandler == nil {
24✔
316
                es.VmHandler = vmHandler
×
317
        }
×
318

319
        if es.TokenSecretHandler == nil {
24✔
320
                es.TokenSecretHandler = secretHandler
×
321
        }
×
322

323
        if es.TokenGetter == nil {
24✔
324
                es.TokenGetter = func() (string, error) {
×
325
                        return getToken(es.TokenFile)
×
326
                }
×
327
        }
328

329
        if es.PermissionChecker == nil {
24✔
330
                es.PermissionChecker = checkVolumePermissions
×
331
        }
×
332

333
        return es
24✔
334
}
335

336
var getExpandedVM = func() *virtv1.VirtualMachine {
×
337
        f, err := os.Open(vmManifestPath)
×
338
        if err != nil {
×
339
                log.Log.Reason(err).Info("Unable to load VM manifest data")
×
340
                return nil
×
341
        }
×
342
        defer f.Close()
×
343
        fileinfo, err := f.Stat()
×
344
        if err != nil {
×
345
                log.Log.Reason(err).Info("Unable to load VM manifest data")
×
346
                return nil
×
347
        }
×
348
        buf := make([]byte, fileinfo.Size())
×
349
        _, err = f.Read(buf)
×
350
        if err != nil {
×
351
                log.Log.Reason(err).Info("Unable to load VM manifest data")
×
352
                return nil
×
353
        }
×
354

355
        vm := &virtv1.VirtualMachine{}
×
356
        if err := json.Unmarshal(buf, vm); err != nil {
×
357
                log.Log.Reason(err).Info("Unable to load VM manifest data")
×
358
                return nil
×
359
        }
×
360
        return vm
×
361
}
362

363
var getInternalBasePath = func() (string, error) {
×
364
        data, err := os.ReadFile(internalLinkPath)
×
365
        if err != nil {
×
366
                return "", err
×
367
        }
×
368
        return string(data), nil
×
369
}
370

371
var getExportName = func() (string, error) {
×
372
        data, err := os.ReadFile(exportNamePath)
×
373
        if err != nil {
×
374
                return "", err
×
375
        }
×
376
        return string(data), nil
×
377
}
378

379
var getExternalBasePath = func() (string, error) {
×
380
        data, err := os.ReadFile(externalLinkPath)
×
381
        if err != nil {
×
382
                return "", err
×
383
        }
×
384
        return string(data), nil
×
385
}
386

387
func GetTypeMetaString(gvk schema.GroupVersionKind) string {
×
388
        return fmt.Sprintf("apiVersion: %s\nkind: %s\n", gvk.GroupVersion().String(), gvk.Kind)
×
389
}
×
390

391
var getCAConfigMap = func(name string) (*corev1.ConfigMap, error) {
×
392
        f, err := os.Open(name)
×
393
        if err != nil {
×
394
                return nil, err
×
395
        }
×
396
        defer f.Close()
×
397
        fileinfo, err := f.Stat()
×
398
        if err != nil {
×
399
                return nil, err
×
400
        }
×
401
        buf := make([]byte, fileinfo.Size())
×
402
        _, err = f.Read(buf)
×
403
        if err != nil {
×
404
                return nil, err
×
405
        }
×
406

407
        cm := &corev1.ConfigMap{}
×
408
        if err := json.Unmarshal(buf, cm); err != nil {
×
409
                return nil, err
×
410
        }
×
411
        return cm, nil
×
412
}
413

414
var getCdiHeaderSecret = func(token, name string) *corev1.Secret {
2✔
415
        data := make(map[string]string)
2✔
416

2✔
417
        data["token"] = fmt.Sprintf("x-kubevirt-export-token:%s", token)
2✔
418
        return &corev1.Secret{
2✔
419
                ObjectMeta: metav1.ObjectMeta{
2✔
420
                        Name: name,
2✔
421
                },
2✔
422
                StringData: data,
2✔
423
        }
2✔
424
}
2✔
425

426
var getDataVolumes = func(vm *virtv1.VirtualMachine) ([]*cdiv1.DataVolume, error) {
×
427
        res := make([]*cdiv1.DataVolume, 0)
×
428
        volumes, err := storageutils.GetVolumes(vm, nil)
×
429
        if err != nil {
×
430
                return nil, err
×
431
        }
×
432
        for _, volume := range volumes {
×
433
                name := ""
×
434
                if volume.DataVolume != nil {
×
435
                        name = volume.DataVolume.Name
×
436
                } else if volume.PersistentVolumeClaim != nil {
×
437
                        name = volume.PersistentVolumeClaim.ClaimName
×
438
                }
×
439
                if name == "" {
×
440
                        continue
×
441
                }
442
                log.Log.V(1).Infof("Opening DV %s", filepath.Join(manifestCmBasePath, fmt.Sprintf("dv-%s", name)))
×
443
                f, err := os.Open(filepath.Join(manifestCmBasePath, fmt.Sprintf("dv-%s", name)))
×
444
                if err != nil {
×
445
                        if errors.Is(err, os.ErrNotExist) {
×
446
                                log.Log.V(1).Info("DV not found skipping")
×
447
                                continue
×
448
                        }
449
                        return nil, err
×
450
                }
451
                defer f.Close()
×
452
                fileinfo, err := f.Stat()
×
453
                if err != nil {
×
454
                        return nil, err
×
455
                }
×
456
                buf := make([]byte, fileinfo.Size())
×
457
                _, err = f.Read(buf)
×
458
                if err != nil {
×
459
                        return nil, err
×
460
                }
×
461
                dv := &cdiv1.DataVolume{}
×
462
                if err := json.Unmarshal(buf, dv); err != nil {
×
463
                        return nil, err
×
464
                }
×
465
                res = append(res, dv)
×
466
        }
467
        return res, nil
×
468
}
469

470
func newTarReader(mountPoint string) (io.ReadCloser, error) {
×
471
        var excludeArgs []string
×
472
        for name := range excludeMap {
×
473
                excludeArgs = append(excludeArgs, "--exclude="+name)
×
474
        }
×
475

476
        args := []string{"Scv"}
×
477
        args = append(args, excludeArgs...)
×
478
        args = append(args, ".")
×
479

×
480
        cmd := exec.Command("/usr/bin/tar", args...)
×
481
        cmd.Dir = mountPoint
×
482
        stdout, err := cmd.StdoutPipe()
×
483
        if err != nil {
×
484
                return nil, err
×
485
        }
×
486
        var stderr bytes.Buffer
×
487
        cmd.Stderr = &stderr
×
488
        if err = cmd.Start(); err != nil {
×
489
                return nil, err
×
490
        }
×
491
        return &execReader{cmd: cmd, stdout: stdout, stderr: io.NopCloser(&stderr)}, nil
×
492
}
493

494
func pipeToGzip(reader io.ReadCloser) io.ReadCloser {
×
495
        pr, pw := io.Pipe()
×
496
        zw := gzip.NewWriter(pw)
×
497

×
498
        go func() {
×
499
                n, err := io.Copy(zw, reader)
×
500
                if err != nil {
×
501
                        log.Log.Reason(err).Error("error piping to gzip")
×
502
                }
×
503
                if err = zw.Close(); err != nil {
×
504
                        log.Log.Reason(err).Error("error closing gzip writer")
×
505
                }
×
506
                if err = pw.Close(); err != nil {
×
507
                        log.Log.Reason(err).Error("error closing pipe writer")
×
508
                }
×
509
                log.Log.Infof("Wrote %d bytes\n", n)
×
510
        }()
511

512
        return pr
×
513
}
514

515
func getTokenQueryParam(r *http.Request) (token string) {
24✔
516
        q := r.URL.Query()
24✔
517
        if keys, ok := q[authHeader]; ok {
36✔
518
                token = keys[0]
12✔
519
                q.Del(authHeader)
12✔
520
                r.URL.RawQuery = q.Encode()
12✔
521
        }
12✔
522
        return
24✔
523
}
524

525
func getTokenHeader(r *http.Request) (token string) {
24✔
526
        if tok := r.Header.Get(authHeader); tok != "" {
36✔
527
                r.Header.Del(authHeader)
12✔
528
                token = tok
12✔
529
        }
12✔
530
        return
24✔
531
}
532

533
func tokenChecker(tokenGetter TokenGetterFunc, nextHandler http.Handler) http.Handler {
32✔
534
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56✔
535
                token, err := tokenGetter()
24✔
536
                if err != nil {
24✔
537
                        w.WriteHeader(http.StatusInternalServerError)
×
538
                        return
×
539
                }
×
540
                for _, tok := range []string{getTokenQueryParam(r), getTokenHeader(r)} {
66✔
541
                        if tok == token {
54✔
542
                                nextHandler.ServeHTTP(w, r)
12✔
543
                                return
12✔
544
                        }
12✔
545
                }
546
                w.WriteHeader(http.StatusUnauthorized)
12✔
547
        })
548
}
549

550
func archiveHandler(mountPoint string) http.Handler {
×
551
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
×
552
                if req.Method != http.MethodGet {
×
553
                        w.WriteHeader(http.StatusBadRequest)
×
554
                        return
×
555
                }
×
556
                if hasPermissions := checkDirectoryPermissions(mountPoint); !hasPermissions {
×
557
                        w.WriteHeader(http.StatusInternalServerError)
×
558
                        return
×
559
                }
×
560

561
                tarReader, err := newTarReader(mountPoint)
×
562
                if err != nil {
×
563
                        log.Log.Reason(err).Error("error creating tar reader")
×
564
                        w.WriteHeader(http.StatusInternalServerError)
×
565
                        return
×
566
                }
×
567
                defer tarReader.Close()
×
568
                gzipReader := pipeToGzip(tarReader)
×
569
                defer gzipReader.Close()
×
570
                n, err := io.Copy(w, gzipReader)
×
571
                if err != nil {
×
572
                        log.Log.Reason(err).Error("error writing response body")
×
573
                }
×
574
                log.Log.Infof("Wrote %d bytes\n", n)
×
575
        })
576
}
577

578
func checkDirectoryPermissions(filePath string) bool {
×
579
        dir, err := os.Open(filePath)
×
580
        if err != nil {
×
581
                log.Log.Reason(err).Errorf("error opening %s", filePath)
×
582
                return false
×
583
        }
×
584
        defer dir.Close()
×
585

×
586
        // Read all filenames
×
587
        contents, err := dir.Readdirnames(-1)
×
588
        if err != nil {
×
589
                log.Log.Reason(err).Errorf("failed to read directory contents: %v", err)
×
590
                return false
×
591
        }
×
592

593
        for _, item := range contents {
×
594
                if _, ok := excludeMap[item]; ok {
×
595
                        continue
×
596
                }
597
                itemPath := filepath.Join(filePath, item)
×
598
                // Check if export server has permissions to manipulate the file
×
599
                file, err := os.Open(itemPath)
×
600
                if err != nil {
×
601
                        log.Log.Reason(err).Errorf("%s may lack read permissions", itemPath)
×
602
                        return false
×
603
                }
×
604
                file.Close()
×
605
        }
606
        return true
×
607
}
608

609
func checkVolumePermissions(path string) bool {
×
610
        fi, err := os.Stat(path)
×
611
        if err != nil {
×
612
                log.Log.Reason(err).Errorf("error statting %s", path)
×
613
                return false
×
614
        }
×
615
        if fi.IsDir() {
×
616
                return checkDirectoryPermissions(path)
×
617
        }
×
618
        f, err := os.Open(path)
×
619
        if err != nil {
×
620
                log.Log.Reason(err).Errorf("error opening %s", path)
×
621
                return false
×
622
        }
×
623
        f.Close()
×
624
        return true
×
625
}
626

627
func gzipHandler(filePath string) http.Handler {
×
628
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
×
629
                if req.Method != http.MethodGet {
×
630
                        w.WriteHeader(http.StatusBadRequest)
×
631
                        return
×
632
                }
×
633
                f, err := os.Open(filePath)
×
634
                if err != nil {
×
635
                        log.Log.Reason(err).Errorf("error opening %s", filePath)
×
636
                        w.WriteHeader(http.StatusInternalServerError)
×
637
                        return
×
638
                }
×
639
                defer f.Close()
×
640
                gzipReader := pipeToGzip(f)
×
641
                defer gzipReader.Close()
×
642
                n, err := io.Copy(w, gzipReader)
×
643
                if err != nil {
×
644
                        log.Log.Reason(err).Error("error writing response body")
×
645
                }
×
646
                log.Log.Infof("Wrote %d bytes\n", n)
×
647
        })
648
}
649

650
func vmHandler(vi []export.VolumeInfo, getBasePath func() (string, error), getCmFunc func() (*corev1.ConfigMap, error)) http.Handler {
14✔
651
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
28✔
652
                if req.Method != http.MethodGet {
18✔
653
                        w.WriteHeader(http.StatusBadRequest)
4✔
654
                        return
4✔
655
                }
4✔
656
                resources := make([]runtime.Object, 0)
10✔
657
                outputFunc := resourceToBytesJson
10✔
658
                contentType := req.Header.Get("Accept")
10✔
659
                if contentType == runtime.ContentTypeYAML {
13✔
660
                        outputFunc = resourceToBytesYaml
3✔
661
                }
3✔
662
                exportName, err := getExportName()
10✔
663
                if err != nil {
11✔
664
                        log.Log.Reason(err).Error("error reading export name")
1✔
665
                        w.WriteHeader(http.StatusInternalServerError)
1✔
666
                        return
1✔
667
                }
1✔
668
                headerSecretName := getSecretTokenName(exportName)
9✔
669
                path, err := getBasePath()
9✔
670
                if err != nil {
11✔
671
                        if errors.Is(err, os.ErrNotExist) {
2✔
672
                                log.Log.Reason(err).Info("path not found")
×
673
                                w.WriteHeader(http.StatusNotFound)
×
674
                        } else {
2✔
675
                                log.Log.Reason(err).Error("error reading path")
2✔
676
                                w.WriteHeader(http.StatusInternalServerError)
2✔
677
                        }
2✔
678
                        return
2✔
679
                }
680
                certCm, error := getCmFunc()
7✔
681
                if error != nil {
8✔
682
                        log.Log.Reason(err).Error("error reading ca configmap information")
1✔
683
                        w.WriteHeader(http.StatusInternalServerError)
1✔
684
                        return
1✔
685
                }
1✔
686
                certCm.TypeMeta = metav1.TypeMeta{
6✔
687
                        Kind:       "ConfigMap",
6✔
688
                        APIVersion: "v1",
6✔
689
                }
6✔
690
                resources = append(resources, certCm)
6✔
691
                expandedVm := getExpandedVM()
6✔
692
                if expandedVm == nil {
7✔
693
                        log.Log.Reason(err).Error("error getting VM definition")
1✔
694
                        w.WriteHeader(http.StatusInternalServerError)
1✔
695
                        return
1✔
696
                }
1✔
697
                expandedVm.TypeMeta = metav1.TypeMeta{
5✔
698
                        Kind:       virtv1.VirtualMachineGroupVersionKind.Kind,
5✔
699
                        APIVersion: virtv1.VirtualMachineGroupVersionKind.GroupVersion().String(),
5✔
700
                }
5✔
701
                for i, dvTemplate := range expandedVm.Spec.DataVolumeTemplates {
7✔
702
                        dvTemplate.Spec.Source.HTTP.URL = fmt.Sprintf("https://%s", filepath.Join(path, vi[i].RawGzURI))
2✔
703
                        dvTemplate.Spec.Source.HTTP.CertConfigMap = certCm.Name
2✔
704
                        dvTemplate.Spec.Source.HTTP.SecretExtraHeaders = []string{headerSecretName}
2✔
705
                }
2✔
706
                resources = append(resources, expandedVm)
5✔
707
                datavolumes, err := getDataVolumes(expandedVm)
5✔
708
                if err != nil {
5✔
709
                        log.Log.Reason(err).Error("error reading datavolumes information")
×
710
                        w.WriteHeader(http.StatusInternalServerError)
×
711
                        return
×
712
                }
×
713
                for _, dv := range datavolumes {
6✔
714
                        dv.TypeMeta = metav1.TypeMeta{
1✔
715
                                Kind:       "DataVolume",
1✔
716
                                APIVersion: "cdi.kubevirt.io/v1beta1",
1✔
717
                        }
1✔
718
                        for _, info := range vi {
2✔
719
                                if strings.Contains(info.RawGzURI, dv.Name) {
2✔
720
                                        dv.Spec.Source.HTTP.URL = fmt.Sprintf("https://%s", filepath.Join(path, info.RawGzURI))
1✔
721
                                }
1✔
722
                        }
723
                        dv.Spec.Source.HTTP.CertConfigMap = certCm.Name
1✔
724
                        dv.Spec.Source.HTTP.SecretExtraHeaders = []string{headerSecretName}
1✔
725
                        resources = append(resources, dv)
1✔
726
                }
727
                data, err := outputFunc(resources)
5✔
728
                if err != nil {
5✔
729
                        w.WriteHeader(http.StatusInternalServerError)
×
730
                        return
×
731
                }
×
732
                n, err := w.Write(data)
5✔
733
                if err != nil {
5✔
734
                        log.Log.Reason(err).Error("error writing manifests")
×
735
                        w.WriteHeader(http.StatusInternalServerError)
×
736
                        return
×
737
                }
×
738
                log.Log.Infof("Wrote %d bytes\n", n)
5✔
739
        })
740
}
741

742
func resourceToBytesJson(resources []runtime.Object) ([]byte, error) {
3✔
743
        list := corev1.List{
3✔
744
                TypeMeta: metav1.TypeMeta{
3✔
745
                        Kind:       "List",
3✔
746
                        APIVersion: "v1",
3✔
747
                },
3✔
748
                ListMeta: metav1.ListMeta{},
3✔
749
        }
3✔
750
        for _, resource := range resources {
8✔
751
                list.Items = append(list.Items, runtime.RawExtension{Object: resource})
5✔
752
        }
5✔
753
        resourceBytes, err := json.MarshalIndent(list, "", "    ")
3✔
754
        if err != nil {
3✔
755
                return nil, err
×
756
        }
×
757
        return resourceBytes, nil
3✔
758
}
759

760
func resourceToBytesYaml(resources []runtime.Object) ([]byte, error) {
4✔
761
        data := []byte{}
4✔
762
        for _, resource := range resources {
12✔
763
                resourceBytes, err := yaml.Marshal(resource)
8✔
764
                if err != nil {
8✔
765
                        return nil, err
×
766
                }
×
767
                data = append(data, resourceBytes...)
8✔
768
                data = append(data, []byte("---\n")...)
8✔
769
        }
770
        return data, nil
4✔
771
}
772

773
func dirHandler(uri, mountPoint string) http.Handler {
×
774
        return http.StripPrefix(uri, http.FileServer(http.Dir(mountPoint)))
×
775
}
×
776

777
func fileHandler(file string) http.Handler {
×
778
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
×
779
                f, err := os.Open(file)
×
780
                if err != nil {
×
781
                        log.Log.Reason(err).Errorf("error opening %s", file)
×
782
                        w.WriteHeader(http.StatusInternalServerError)
×
783
                        return
×
784
                }
×
785
                defer f.Close()
×
786
                http.ServeContent(w, r, "disk.img", time.Time{}, f)
×
787
        })
788
}
789

790
func getToken(tokenFile string) (string, error) {
×
791
        content, err := os.ReadFile(tokenFile)
×
792
        if err != nil {
×
793
                return "", err
×
794
        }
×
795

796
        return string(content), nil
×
797
}
798

799
var getSecretTokenName = func(exportName string) string {
11✔
800
        return fmt.Sprintf("header-secret-%s", exportName)
11✔
801
}
11✔
802

803
func secretHandler(tokenGetter TokenGetterFunc) http.Handler {
8✔
804
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
16✔
805
                if req.Method != http.MethodGet {
12✔
806
                        w.WriteHeader(http.StatusBadRequest)
4✔
807
                        return
4✔
808
                }
4✔
809
                resources := make([]runtime.Object, 0)
4✔
810
                outputFunc := resourceToBytesJson
4✔
811
                contentType := req.Header.Get("Accept")
4✔
812
                if contentType == runtime.ContentTypeYAML {
5✔
813
                        outputFunc = resourceToBytesYaml
1✔
814
                }
1✔
815
                token, err := tokenGetter()
4✔
816
                if err != nil {
5✔
817
                        log.Log.Reason(err).Error("error getting token")
1✔
818
                        w.WriteHeader(http.StatusInternalServerError)
1✔
819
                        return
1✔
820
                }
1✔
821
                exportName, err := getExportName()
3✔
822
                if err != nil {
4✔
823
                        log.Log.Reason(err).Error("error reading export name")
1✔
824
                        w.WriteHeader(http.StatusInternalServerError)
1✔
825
                        return
1✔
826
                }
1✔
827
                headerSecretName := getSecretTokenName(exportName)
2✔
828
                secret := getCdiHeaderSecret(token, headerSecretName)
2✔
829
                secret.TypeMeta = metav1.TypeMeta{
2✔
830
                        Kind:       "Secret",
2✔
831
                        APIVersion: "v1",
2✔
832
                }
2✔
833
                resources = append(resources, secret)
2✔
834
                data, err := outputFunc(resources)
2✔
835
                if err != nil {
2✔
836
                        log.Log.Reason(err).Errorf("error generating secret manifest")
×
837
                        w.WriteHeader(http.StatusInternalServerError)
×
838
                        return
×
839
                }
×
840
                n, err := w.Write(data)
2✔
841
                if err != nil {
2✔
842
                        log.Log.Reason(err).Error("error writing secret manifest")
×
843
                        w.WriteHeader(http.StatusInternalServerError)
×
844
                        return
×
845
                }
×
846
                log.Log.Infof("Wrote %d bytes\n", n)
2✔
847
        })
848
}
849

850
func (s *exportServer) readyHandler(w http.ResponseWriter, r *http.Request) {
×
851
        io.WriteString(w, "OK")
×
852
}
×
853

854
func (s *exportServer) handleTunnel(w http.ResponseWriter, r *http.Request) {
5✔
855
        if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
7✔
856
                log.Log.Error("tunnel rejected: no client certificate presented")
2✔
857
                http.Error(w, "mTLS required", http.StatusUnauthorized)
2✔
858
                return
2✔
859
        }
2✔
860

861
        expectedCN := fmt.Sprintf("kubevirt.io:system:client:%s", s.BackupUID)
3✔
862
        clientCN := r.TLS.PeerCertificates[0].Subject.CommonName
3✔
863
        if clientCN != expectedCN {
4✔
864
                log.Log.Errorf("identity mismatch, cert: %s, expected: %s", clientCN, expectedCN)
1✔
865
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
866
                return
1✔
867
        }
1✔
868

869
        s.nbdMu.Lock()
2✔
870
        if s.nbdClient != nil {
3✔
871
                s.nbdMu.Unlock()
1✔
872
                _ = r.Body.Close()
1✔
873
                log.Log.Warning("rejecting tunnel: active session already exists")
1✔
874
                http.Error(w, "Conflict", http.StatusConflict)
1✔
875
                return
1✔
876
        }
1✔
877

878
        ctx, cancel := context.WithCancel(r.Context())
1✔
879
        defer cancel()
1✔
880
        conn := newH2ServerConn(r.Body, w, cancel)
1✔
881

1✔
882
        var dialOnce sync.Once
1✔
883
        clientConn, err := grpc.NewClient(
1✔
884
                "passthrough:///backup",
1✔
885
                grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) {
1✔
NEW
886
                        var c net.Conn
×
NEW
887
                        dialOnce.Do(func() { c = conn })
×
NEW
888
                        if c != nil {
×
NEW
889
                                return c, nil
×
NEW
890
                        }
×
NEW
891
                        return nil, fmt.Errorf("tunnel connection is single-use; reconnect not supported")
×
892
                }),
893
                grpc.WithTransportCredentials(insecure.NewCredentials()),
894
                grpc.WithKeepaliveParams(keepalive.ClientParameters{
895
                        Time:                10 * time.Second,
896
                        Timeout:             5 * time.Second,
897
                        PermitWithoutStream: true,
898
                }),
899
        )
900
        if err != nil {
1✔
NEW
901
                s.nbdMu.Unlock()
×
NEW
902
                log.Log.Reason(err).Error("failed to initialize gRPC client for tunnel")
×
NEW
903
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
×
NEW
904
                return
×
NEW
905
        }
×
906

907
        w.WriteHeader(http.StatusOK)
1✔
908
        w.(http.Flusher).Flush()
1✔
909

1✔
910
        s.nbdClient = nbdv1.NewNBDClient(clientConn)
1✔
911
        s.nbdMu.Unlock()
1✔
912

1✔
913
        log.Log.Infof("Exclusive backup tunnel established for %s", s.BackupUID)
1✔
914

1✔
915
        <-ctx.Done()
1✔
916

1✔
917
        s.nbdMu.Lock()
1✔
918
        clientConn.Close()
1✔
919
        s.nbdClient = nil
1✔
920
        s.nbdMu.Unlock()
1✔
921
        log.Log.Info("Backup tunnel disconnected, listener reset")
1✔
922
}
923

924
type h2ServerConn struct {
925
        r      io.ReadCloser
926
        w      http.ResponseWriter
927
        cancel context.CancelFunc
928
        once   sync.Once
929
}
930

931
func newH2ServerConn(r io.ReadCloser, w http.ResponseWriter, cancel context.CancelFunc) *h2ServerConn {
2✔
932
        return &h2ServerConn{r: r, w: w, cancel: cancel}
2✔
933
}
2✔
934

935
func (c *h2ServerConn) Read(b []byte) (int, error) { return c.r.Read(b) }
1✔
936

937
func (c *h2ServerConn) Write(b []byte) (int, error) {
1✔
938
        n, err := c.w.Write(b)
1✔
939
        if f, ok := c.w.(http.Flusher); ok {
2✔
940
                f.Flush()
1✔
941
        }
1✔
942
        return n, err
1✔
943
}
944

945
func (c *h2ServerConn) Close() error {
1✔
946
        c.once.Do(c.cancel)
1✔
947
        return c.r.Close()
1✔
948
}
1✔
949

NEW
950
func (c *h2ServerConn) LocalAddr() net.Addr                { return h2DummyAddr }
×
NEW
951
func (c *h2ServerConn) RemoteAddr() net.Addr               { return h2DummyAddr }
×
NEW
952
func (c *h2ServerConn) SetDeadline(_ time.Time) error      { return nil }
×
NEW
953
func (c *h2ServerConn) SetReadDeadline(_ time.Time) error  { return nil }
×
NEW
954
func (c *h2ServerConn) SetWriteDeadline(_ time.Time) error { return nil }
×
955

956
type ExportMapExtent struct {
957
        Offset      uint64 `json:"offset"`
958
        Length      uint64 `json:"length"`
959
        Type        uint64 `json:"type"`
960
        Description string `json:"description"`
961
}
962

963
type ExportMapResponse struct {
964
        Extents    []ExportMapExtent `json:"extents"`
965
        NextOffset *uint64           `json:"next_offset"`
966
}
967

968
func (s *exportServer) backupMapHandler(exportName string) http.Handler {
15✔
969
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
30✔
970
                if req.Method != http.MethodGet {
19✔
971
                        w.WriteHeader(http.StatusMethodNotAllowed)
4✔
972
                        return
4✔
973
                }
4✔
974

975
                s.nbdMu.RLock()
11✔
976
                client := s.nbdClient
11✔
977
                s.nbdMu.RUnlock()
11✔
978
                if client == nil {
12✔
979
                        http.Error(w, "Backup source (virt-launcher) not connected via tunnel", http.StatusServiceUnavailable)
1✔
980
                        return
1✔
981
                }
1✔
982

983
                offset := uint64(0)
10✔
984
                length := uint64(0)
10✔
985
                pageSize := defaultMapPageSize
10✔
986
                query := req.URL.Query()
10✔
987

10✔
988
                if offsetStr := query.Get("offset"); offsetStr != "" {
12✔
989
                        o, err := strconv.ParseUint(offsetStr, 10, 64)
2✔
990
                        if err != nil {
3✔
991
                                http.Error(w, fmt.Sprintf("invalid offset %q: %v", offsetStr, err), http.StatusBadRequest)
1✔
992
                                return
1✔
993
                        }
1✔
994
                        offset = o
1✔
995
                }
996
                if lengthStr := query.Get("length"); lengthStr != "" {
11✔
997
                        l, err := strconv.ParseUint(lengthStr, 10, 64)
2✔
998
                        if err != nil {
3✔
999
                                http.Error(w, fmt.Sprintf("invalid length %q: %v", lengthStr, err), http.StatusBadRequest)
1✔
1000
                                return
1✔
1001
                        }
1✔
1002
                        length = l
1✔
1003
                }
1004
                if pageSizeStr := query.Get("page_size"); pageSizeStr != "" {
11✔
1005
                        p, err := strconv.Atoi(pageSizeStr)
3✔
1006
                        if err != nil || p <= 0 {
5✔
1007
                                http.Error(w, fmt.Sprintf("invalid page_size %q", pageSizeStr), http.StatusBadRequest)
2✔
1008
                                return
2✔
1009
                        }
2✔
1010
                        pageSize = p
1✔
1011
                }
1012

1013
                var bitmapName string
6✔
1014
                if s.BackupType == string(backupv1.Incremental) && s.BackupCheckpoint != "" {
7✔
1015
                        bitmapName = s.BackupCheckpoint
1✔
1016
                }
1✔
1017

1018
                streamCtx, streamCancel := context.WithCancel(req.Context())
6✔
1019
                defer streamCancel()
6✔
1020

6✔
1021
                stream, err := client.Map(streamCtx, &nbdv1.MapRequest{
6✔
1022
                        ExportName: exportName,
6✔
1023
                        BitmapName: bitmapName,
6✔
1024
                        Offset:     offset,
6✔
1025
                        Length:     length,
6✔
1026
                })
6✔
1027
                if err != nil {
7✔
1028
                        errMsg := fmt.Sprintf("Failed to call map for export: %s", exportName)
1✔
1029
                        log.Log.Reason(err).Error(errMsg)
1✔
1030
                        http.Error(w, errMsg, http.StatusInternalServerError)
1✔
1031
                        return
1✔
1032
                }
1✔
1033

1034
                extents, nextOffsetPtr, err := collectMapPage(stream, pageSize)
5✔
1035
                if err != nil {
6✔
1036
                        errMsg := fmt.Sprintf("Failed to collect map extents for export: %s", exportName)
1✔
1037
                        log.Log.Reason(err).Error(errMsg)
1✔
1038
                        http.Error(w, errMsg, http.StatusInternalServerError)
1✔
1039
                        return
1✔
1040
                }
1✔
1041

1042
                page := ExportMapResponse{
4✔
1043
                        Extents:    extents,
4✔
1044
                        NextOffset: nextOffsetPtr,
4✔
1045
                }
4✔
1046

4✔
1047
                w.Header().Set("Content-Type", "application/json")
4✔
1048
                if err := json.NewEncoder(w).Encode(page); err != nil {
4✔
NEW
1049
                        log.Log.Reason(err).Errorf("failed to encode map page for export %s", exportName)
×
NEW
1050
                }
×
1051
        })
1052
}
1053

1054
func (s *exportServer) backupDataHandler(exportName string) http.Handler {
11✔
1055
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
22✔
1056
                if req.Method != http.MethodGet {
15✔
1057
                        w.WriteHeader(http.StatusMethodNotAllowed)
4✔
1058
                        return
4✔
1059
                }
4✔
1060

1061
                s.nbdMu.RLock()
7✔
1062
                client := s.nbdClient
7✔
1063
                s.nbdMu.RUnlock()
7✔
1064

7✔
1065
                if client == nil {
8✔
1066
                        http.Error(w, "Backup source not connected", http.StatusServiceUnavailable)
1✔
1067
                        return
1✔
1068
                }
1✔
1069

1070
                offset := uint64(0)
6✔
1071
                length := uint64(0)
6✔
1072

6✔
1073
                query := req.URL.Query()
6✔
1074
                if offsetStr := query.Get("offset"); offsetStr != "" {
9✔
1075
                        o, err := strconv.ParseUint(offsetStr, 10, 64)
3✔
1076
                        if err != nil {
4✔
1077
                                http.Error(w, fmt.Sprintf("invalid offset %q: %v", offsetStr, err), http.StatusBadRequest)
1✔
1078
                                return
1✔
1079
                        }
1✔
1080
                        offset = o
2✔
1081
                }
1082
                if lengthStr := query.Get("length"); lengthStr != "" {
8✔
1083
                        l, err := strconv.ParseUint(lengthStr, 10, 64)
3✔
1084
                        if err != nil {
4✔
1085
                                http.Error(w, fmt.Sprintf("invalid length %q: %v", lengthStr, err), http.StatusBadRequest)
1✔
1086
                                return
1✔
1087
                        }
1✔
1088
                        length = l
2✔
1089
                }
1090

1091
                stream, err := client.Read(req.Context(), &nbdv1.ReadRequest{
4✔
1092
                        ExportName: exportName,
4✔
1093
                        Offset:     offset,
4✔
1094
                        Length:     length,
4✔
1095
                })
4✔
1096
                if err != nil {
5✔
1097
                        http.Error(w, fmt.Sprintf("Failed to call read for export: %s", exportName), http.StatusInternalServerError)
1✔
1098
                        return
1✔
1099
                }
1✔
1100

1101
                w.Header().Set("Content-Type", "application/octet-stream")
3✔
1102
                for {
8✔
1103
                        chunk, err := stream.Recv()
5✔
1104
                        if errors.Is(err, io.EOF) {
8✔
1105
                                break
3✔
1106
                        }
1107
                        if err != nil {
2✔
NEW
1108
                                log.Log.Reason(err).Error("Tunnel stream interrupted")
×
NEW
1109
                                panic(http.ErrAbortHandler)
×
1110
                        }
1111
                        if _, err := w.Write(chunk.Data); err != nil {
2✔
NEW
1112
                                log.Log.Reason(err).Error("HTTP client disconnected during stream")
×
NEW
1113
                                return
×
NEW
1114
                        }
×
1115
                        if f, ok := w.(http.Flusher); ok {
4✔
1116
                                f.Flush()
2✔
1117
                        }
2✔
1118
                }
1119
        })
1120
}
1121

1122
func collectMapPage(stream nbdv1.NBD_MapClient, pageSize int) ([]ExportMapExtent, *uint64, error) {
9✔
1123
        var extents []ExportMapExtent
9✔
1124
        for {
26✔
1125
                msg, err := stream.Recv()
17✔
1126
                if errors.Is(err, io.EOF) {
22✔
1127
                        return extents, nil, nil
5✔
1128
                }
5✔
1129
                if err != nil {
14✔
1130
                        return nil, nil, err
2✔
1131
                }
2✔
1132
                for _, e := range msg.Extents {
20✔
1133
                        if len(extents) >= pageSize {
12✔
1134
                                return extents, &e.Offset, nil
2✔
1135
                        }
2✔
1136
                        extents = append(extents, ExportMapExtent{
8✔
1137
                                Offset:      e.Offset,
8✔
1138
                                Length:      e.Length,
8✔
1139
                                Type:        e.Flags,
8✔
1140
                                Description: e.Description,
8✔
1141
                        })
8✔
1142
                }
1143
        }
1144
}
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