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

google / alioth / 19401536892

16 Nov 2025 06:23AM UTC coverage: 26.356% (+0.02%) from 26.337%
19401536892

Pull #329

github

web-flow
Merge fa7f2f2b0 into a844b2ad9
Pull Request #329: fw-cfg device refactor

396 of 648 branches covered (61.11%)

Branch coverage included in aggregate %.

0 of 83 new or added lines in 11 files covered. (0.0%)

290 existing lines in 8 files now uncovered.

3564 of 14377 relevant lines covered (24.79%)

21.75 hits per line

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

0.0
/alioth-cli/src/boot.rs
1
// Copyright 2025 Google LLC
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     https://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14

15
use std::collections::HashMap;
16
use std::ffi::CString;
17
#[cfg(target_arch = "x86_64")]
18
use std::fs::File;
19
use std::path::PathBuf;
20

21
use alioth::board::BoardConfig;
22
#[cfg(target_arch = "x86_64")]
23
use alioth::device::fw_cfg::FwCfgItemParam;
24
use alioth::errors::{DebugTrace, trace_error};
25
#[cfg(target_os = "macos")]
26
use alioth::hv::Hvf;
27
use alioth::hv::{self, Coco};
28
#[cfg(target_os = "linux")]
29
use alioth::hv::{Kvm, KvmConfig};
30
use alioth::loader::{ExecType, Payload};
31
use alioth::mem::{MemBackend, MemConfig};
32
#[cfg(target_os = "linux")]
33
use alioth::vfio::{CdevParam, ContainerParam, GroupParam, IoasParam};
34
#[cfg(target_os = "linux")]
35
use alioth::virtio::DeviceId;
36
use alioth::virtio::dev::balloon::BalloonParam;
37
use alioth::virtio::dev::blk::BlkFileParam;
38
use alioth::virtio::dev::entropy::EntropyParam;
39
use alioth::virtio::dev::fs::shared_dir::SharedDirParam;
40
#[cfg(target_os = "linux")]
41
use alioth::virtio::dev::fs::vu::VuFsParam;
42
#[cfg(target_os = "linux")]
43
use alioth::virtio::dev::net::tap::NetTapParam;
44
#[cfg(target_os = "macos")]
45
use alioth::virtio::dev::net::vmnet::NetVmnetParam;
46
use alioth::virtio::dev::vsock::UdsVsockParam;
47
#[cfg(target_os = "linux")]
48
use alioth::virtio::dev::vsock::VhostVsockParam;
49
#[cfg(target_os = "linux")]
50
use alioth::virtio::vu::frontend::VuFrontendParam;
51
use alioth::vm::Machine;
52
use clap::Args;
53
use serde::Deserialize;
54
use serde_aco::{Help, help_text};
55
use snafu::{ResultExt, Snafu};
56

57
use crate::objects::{DOC_OBJECTS, parse_objects};
58

59
#[trace_error]
UNCOV
60
#[derive(Snafu, DebugTrace)]
×
61
#[snafu(module, context(suffix(false)))]
62
pub enum Error {
63
    #[snafu(display("Failed to parse {arg}"))]
64
    ParseArg {
65
        arg: String,
66
        error: serde_aco::Error,
67
    },
68
    #[snafu(display("Failed to parse objects"), context(false))]
69
    ParseObjects { source: crate::objects::Error },
70
    #[cfg(target_os = "linux")]
71
    #[snafu(display("Failed to access system hypervisor"))]
72
    Hypervisor { source: alioth::hv::Error },
73
    #[snafu(display("Failed to create a VM"))]
74
    CreateVm { source: alioth::vm::Error },
75
    #[snafu(display("Failed to create a device"))]
76
    CreateDevice { source: alioth::vm::Error },
77
    #[cfg(target_arch = "x86_64")]
78
    #[snafu(display("Failed to open {path:?}"))]
79
    OpenFile {
80
        path: PathBuf,
81
        error: std::io::Error,
82
    },
83
    #[cfg(target_arch = "x86_64")]
84
    #[snafu(display("Failed to configure the fw-cfg device"))]
85
    FwCfg { error: std::io::Error },
86
    #[snafu(display("Failed to boot a VM"))]
87
    BootVm { source: alioth::vm::Error },
88
    #[snafu(display("VM did not shutdown peacefully"))]
89
    WaitVm { source: alioth::vm::Error },
90
}
91

92
#[derive(Debug, Deserialize, Clone, Help)]
93
#[cfg_attr(target_os = "macos", derive(Default))]
94
enum Hypervisor {
95
    /// KVM backed by the Linux kernel.
96
    #[cfg(target_os = "linux")]
97
    #[serde(alias = "kvm")]
98
    Kvm(KvmConfig),
99
    /// macOS Hypervisor Framework.
100
    #[cfg(target_os = "macos")]
101
    #[serde(alias = "hvf")]
102
    #[default]
103
    Hvf,
104
}
105

106
#[cfg(target_os = "linux")]
107
impl Default for Hypervisor {
UNCOV
108
    fn default() -> Self {
×
UNCOV
109
        Hypervisor::Kvm(KvmConfig::default())
×
UNCOV
110
    }
×
111
}
112

113
#[derive(Debug, Deserialize, Clone, Help)]
114
enum FsParam {
115
    /// VirtIO FS device backed by a shared directory.
116
    #[serde(alias = "dir")]
117
    Dir(SharedDirParam),
118
    #[cfg(target_os = "linux")]
119
    /// VirtIO FS device backed by a vhost-user process, e.g. virtiofsd.
120
    #[serde(alias = "vu")]
121
    Vu(VuFsParam),
122
}
123

124
#[derive(Debug, Deserialize, Clone, Help)]
125
enum VsockParam {
126
    #[cfg(target_os = "linux")]
127
    /// Vsock device backed by host kernel vhost-vsock module.
128
    #[serde(alias = "vhost")]
129
    Vhost(VhostVsockParam),
130
    /// Vsock device mapped to a Unix domain socket.
131
    #[serde(alias = "uds")]
132
    Uds(UdsVsockParam),
133
}
134

135
#[cfg(target_os = "linux")]
136
#[derive(Deserialize, Help)]
137
struct VuSocket {
138
    socket: PathBuf,
139
}
140

141
#[derive(Deserialize, Help)]
142
enum NetParam {
143
    /// VirtIO net device backed by TUN/TAP, MacVTap, or IPVTap.
144
    #[cfg(target_os = "linux")]
145
    #[serde(alias = "tap")]
146
    Tap(NetTapParam),
147
    /// VirtIO net device backed by vmnet framework.
148
    #[cfg(target_os = "macos")]
149
    #[serde(alias = "vmnet")]
150
    Vmnet(NetVmnetParam),
151
    /// vhost-user net device over a Unix domain socket.
152
    #[cfg(target_os = "linux")]
153
    #[serde(alias = "vu")]
154
    Vu(VuSocket),
155
}
156

157
#[derive(Deserialize, Help)]
158
enum BlkParam {
159
    /// VirtIO block device backed a disk image file.
160
    #[serde(alias = "file")]
161
    File(BlkFileParam),
162
    #[cfg(target_os = "linux")]
163
    #[serde(alias = "vu")]
164
    /// vhost-user block device over a Unix domain socket.
165
    Vu(VuSocket),
166
}
167

168
#[derive(Args, Debug, Clone)]
169
#[command(arg_required_else_help = true, alias("run"))]
170
pub struct BootArgs {
171
    #[arg(long, help(
172
        help_text::<Hypervisor>("Specify the Hypervisor to run on.")
173
    ), value_name = "HV")]
174
    hypervisor: Option<String>,
175

176
    /// Path to a Linux kernel image.
177
    #[arg(short, long, value_name = "PATH")]
178
    kernel: Option<PathBuf>,
179

180
    /// Path to an ELF kernel with PVH note.
181
    #[cfg(target_arch = "x86_64")]
182
    #[arg(long, value_name = "PATH")]
183
    pvh: Option<PathBuf>,
184

185
    /// Path to a firmware image.
186
    #[arg(long, short, value_name = "PATH")]
187
    firmware: Option<PathBuf>,
188

189
    /// Command line to pass to the kernel, e.g. `console=ttyS0`.
190
    #[arg(short, long, alias = "cmd-line", value_name = "ARGS")]
191
    cmdline: Option<CString>,
192

193
    /// Path to an initramfs image.
194
    #[arg(short, long, value_name = "PATH")]
195
    initramfs: Option<PathBuf>,
196

197
    /// Number of VCPUs assigned to the guest.
198
    #[arg(long, default_value_t = 1)]
199
    num_cpu: u32,
200

201
    /// DEPRECATED: Use --memory instead.
202
    #[arg(long, default_value = "1G")]
203
    mem_size: String,
204

205
    #[arg(short, long, help(
206
        help_text::<MemConfig>("Specify the memory of the guest.")
207
    ))]
208
    memory: Option<String>,
209

210
    /// Add a pvpanic device.
211
    #[arg(long)]
212
    pvpanic: bool,
213

214
    #[cfg(target_arch = "x86_64")]
215
    #[arg(long = "fw-cfg", help(
216
        help_text::<FwCfgItemParam>("Add an extra item to the fw_cfg device.")
217
    ), value_name = "ITEM")]
218
    fw_cfgs: Vec<String>,
219

220
    /// Add a VirtIO entropy device.
221
    #[arg(long)]
222
    entropy: bool,
223

224
    #[arg(long, help(
225
        help_text::<NetParam>("Add a VirtIO net device.")
226
    ))]
227
    net: Vec<String>,
228

229
    #[arg(long, help(
230
        help_text::<BlkParam>("Add a VirtIO block device.")
231
    ))]
232
    blk: Vec<String>,
233

234
    #[arg(long, help(
235
        help_text::<Coco>("Enable confidential compute supported by host platform.")
236
    ))]
237
    coco: Option<String>,
238

239
    #[arg(long, help(
240
        help_text::<FsParam>("Add a VirtIO filesystem device.")
241
    ))]
242
    fs: Vec<String>,
243

244
    #[arg(long, help(
245
        help_text::<VsockParam>("Add a VirtIO vsock device.")
246
    ))]
247
    vsock: Option<String>,
248

249
    #[cfg(target_os = "linux")]
250
    #[arg(long, help(help_text::<CdevParam>(
251
        "Assign a host PCI device to the guest using IOMMUFD API."
252
    ) ))]
253
    vfio_cdev: Vec<String>,
254

255
    #[cfg(target_os = "linux")]
256
    #[arg(long, help(help_text::<IoasParam>("Create a new IO address space.")))]
257
    vfio_ioas: Vec<String>,
258

259
    #[cfg(target_os = "linux")]
260
    #[arg(long, help(help_text::<GroupParam>(
261
        "Assign a host PCI device to the guest using legacy VFIO API."
262
    )))]
263
    vfio_group: Vec<String>,
264

265
    #[cfg(target_os = "linux")]
266
    #[arg(long, help(help_text::<ContainerParam>("Add a new VFIO container.")))]
267
    vfio_container: Vec<String>,
268

269
    #[arg(long)]
270
    #[arg(long, help(help_text::<BalloonParam>("Add a VirtIO balloon device.")))]
271
    balloon: Option<String>,
272

273
    #[arg(short, long("object"), help = DOC_OBJECTS, value_name = "OBJECT")]
274
    objects: Vec<String>,
275
}
276

277
fn add_net<H>(
×
278
    vm: &Machine<H>,
×
UNCOV
279
    args: Vec<String>,
×
280
    objects: &HashMap<&str, &str>,
×
281
) -> Result<(), Error>
×
282
where
×
UNCOV
283
    H: hv::Hypervisor + 'static,
×
284
{
UNCOV
285
    for (index, arg) in args.into_iter().enumerate() {
×
286
        #[cfg(target_os = "linux")]
287
        let param: NetParam = match serde_aco::from_args(&arg, objects) {
×
288
            Ok(p) => p,
×
289
            Err(_) => {
290
                let tap_param = serde_aco::from_args::<NetTapParam>(&arg, objects)
×
UNCOV
291
                    .context(error::ParseArg { arg })?;
×
292
                NetParam::Tap(tap_param)
×
293
            }
294
        };
295
        #[cfg(target_os = "macos")]
296
        let param: NetParam =
×
297
            serde_aco::from_args(&arg, objects).context(error::ParseArg { arg })?;
×
UNCOV
298
        match param {
×
299
            #[cfg(target_os = "linux")]
300
            NetParam::Tap(tap_param) => vm.add_virtio_dev(format!("virtio-net-{index}"), tap_param),
×
301
            #[cfg(target_os = "linux")]
302
            NetParam::Vu(sock) => {
×
UNCOV
303
                let param = VuFrontendParam {
×
304
                    id: DeviceId::Net,
×
305
                    socket: sock.socket,
×
UNCOV
306
                };
×
307
                vm.add_virtio_dev(format!("vu-net-{index}"), param)
×
308
            }
309
            #[cfg(target_os = "macos")]
310
            NetParam::Vmnet(p) => vm.add_virtio_dev(format!("virtio-net-{index}"), p),
×
311
        }
312
        .context(error::CreateDevice)?;
×
313
    }
UNCOV
314
    Ok(())
×
315
}
×
316

317
fn add_blk<H>(
×
318
    vm: &Machine<H>,
×
319
    args: Vec<String>,
×
UNCOV
320
    objects: &HashMap<&str, &str>,
×
321
) -> Result<(), Error>
×
322
where
×
NEW
323
    H: hv::Hypervisor + 'static,
×
324
{
NEW
325
    for (index, opt) in args.into_iter().enumerate() {
×
UNCOV
326
        let param: BlkParam = match serde_aco::from_args(&opt, objects) {
×
UNCOV
327
            Ok(param) => param,
×
UNCOV
328
            Err(_) => match serde_aco::from_args(&opt, objects) {
×
UNCOV
329
                Ok(param) => BlkParam::File(param),
×
330
                Err(_) => {
331
                    eprintln!("Please update the cmd line to --blk file,path={opt}");
×
UNCOV
332
                    BlkParam::File(BlkFileParam {
×
333
                        path: opt.into(),
×
334
                        ..Default::default()
×
335
                    })
×
336
                }
337
            },
338
        };
UNCOV
339
        match param {
×
UNCOV
340
            BlkParam::File(p) => vm.add_virtio_dev(format!("virtio-blk-{index}"), p),
×
341
            #[cfg(target_os = "linux")]
UNCOV
342
            BlkParam::Vu(s) => {
×
343
                let p = VuFrontendParam {
×
344
                    id: DeviceId::Block,
×
UNCOV
345
                    socket: s.socket,
×
346
                };
×
347
                vm.add_virtio_dev(format!("vu-net-{index}"), p)
×
348
            }
349
        }
UNCOV
350
        .context(error::CreateDevice)?;
×
351
    }
UNCOV
352
    Ok(())
×
353
}
×
354

355
pub fn boot(args: BootArgs) -> Result<(), Error> {
×
UNCOV
356
    let objects = parse_objects(&args.objects)?;
×
357
    let hv_config = if let Some(hv_cfg_opt) = args.hypervisor {
×
UNCOV
358
        serde_aco::from_args(&hv_cfg_opt, &objects).context(error::ParseArg { arg: hv_cfg_opt })?
×
359
    } else {
360
        Hypervisor::default()
×
361
    };
UNCOV
362
    let hypervisor = match hv_config {
×
363
        #[cfg(target_os = "linux")]
364
        Hypervisor::Kvm(kvm_config) => Kvm::new(kvm_config).context(error::Hypervisor)?,
×
365
        #[cfg(target_os = "macos")]
UNCOV
366
        Hypervisor::Hvf => Hvf {},
×
367
    };
368
    let coco = match args.coco {
×
UNCOV
369
        None => None,
×
UNCOV
370
        Some(c) => Some(serde_aco::from_args(&c, &objects).context(error::ParseArg { arg: c })?),
×
371
    };
372
    let mem_config = if let Some(s) = args.memory {
×
373
        serde_aco::from_args(&s, &objects).context(error::ParseArg { arg: s })?
×
374
    } else {
375
        #[cfg(target_os = "linux")]
376
        eprintln!(
×
377
            "Please update the cmd line to --memory size={},backend=memfd",
378
            args.mem_size
379
        );
380
        let size = serde_aco::from_args(&args.mem_size, &objects)
×
UNCOV
381
            .context(error::ParseArg { arg: args.mem_size })?;
×
382
        MemConfig {
×
383
            size,
×
384
            #[cfg(target_os = "linux")]
×
385
            backend: MemBackend::Memfd,
×
386
            #[cfg(not(target_os = "linux"))]
×
387
            backend: MemBackend::Anonymous,
×
UNCOV
388
            ..Default::default()
×
389
        }
×
390
    };
391
    let board_config = BoardConfig {
×
UNCOV
392
        mem: mem_config,
×
393
        num_cpu: args.num_cpu,
×
UNCOV
394
        coco,
×
395
    };
×
396
    let vm = Machine::new(hypervisor, board_config).context(error::CreateVm)?;
×
397
    #[cfg(target_arch = "x86_64")]
UNCOV
398
    vm.add_com1().context(error::CreateDevice)?;
×
399
    #[cfg(target_arch = "aarch64")]
400
    vm.add_pl011().context(error::CreateDevice)?;
×
401
    #[cfg(target_arch = "aarch64")]
402
    vm.add_pl031();
×
403

404
    if args.pvpanic {
×
405
        vm.add_pvpanic().context(error::CreateDevice)?;
×
NEW
406
    }
×
407

408
    #[cfg(target_arch = "x86_64")]
UNCOV
409
    if args.firmware.is_some() || !args.fw_cfgs.is_empty() {
×
410
        let params = args
×
411
            .fw_cfgs
×
412
            .into_iter()
×
413
            .map(|s| serde_aco::from_args(&s, &objects).context(error::ParseArg { arg: s }))
×
414
            .collect::<Result<Vec<_>, _>>()?;
×
415
        let fw_cfg = vm
×
416
            .add_fw_cfg(params.into_iter())
×
417
            .context(error::CreateDevice)?;
×
418
        let mut dev = fw_cfg.lock();
×
419

420
        if let Some(kernel) = &args.kernel {
×
UNCOV
421
            dev.add_kernel_data(File::open(kernel).context(error::OpenFile { path: kernel })?)
×
422
                .context(error::FwCfg)?
×
UNCOV
423
        }
×
424
        if let Some(initramfs) = &args.initramfs {
×
UNCOV
425
            dev.add_initramfs_data(
×
426
                File::open(initramfs).context(error::OpenFile { path: initramfs })?,
×
427
            )
428
            .context(error::FwCfg)?;
×
429
        }
×
UNCOV
430
        if let Some(cmdline) = &args.cmdline {
×
431
            dev.add_kernel_cmdline(cmdline.clone());
×
432
        }
×
433
    };
×
434

435
    if args.entropy {
×
436
        vm.add_virtio_dev("virtio-entropy", EntropyParam::default())
×
UNCOV
437
            .context(error::CreateDevice)?;
×
438
    }
×
439
    add_net(&vm, args.net, &objects)?;
×
440
    add_blk(&vm, args.blk, &objects)?;
×
441
    for (index, fs) in args.fs.into_iter().enumerate() {
×
442
        let param: FsParam =
×
443
            serde_aco::from_args(&fs, &objects).context(error::ParseArg { arg: fs })?;
×
444
        match param {
×
UNCOV
445
            FsParam::Dir(p) => vm.add_virtio_dev(format!("virtio-fs-{index}"), p),
×
446
            #[cfg(target_os = "linux")]
447
            FsParam::Vu(p) => vm.add_virtio_dev(format!("vu-fs-{index}"), p),
×
448
        }
449
        .context(error::CreateDevice)?;
×
450
    }
UNCOV
451
    if let Some(vsock) = args.vsock {
×
UNCOV
452
        let param =
×
453
            serde_aco::from_args(&vsock, &objects).context(error::ParseArg { arg: vsock })?;
×
454
        match param {
×
455
            #[cfg(target_os = "linux")]
456
            VsockParam::Vhost(p) => vm
×
457
                .add_virtio_dev("vhost-vsock", p)
×
UNCOV
458
                .context(error::CreateDevice)?,
×
UNCOV
459
            VsockParam::Uds(p) => vm
×
UNCOV
460
                .add_virtio_dev("uds-vsock", p)
×
461
                .context(error::CreateDevice)?,
×
462
        };
463
    }
×
464
    if let Some(balloon) = args.balloon {
×
UNCOV
465
        let param: BalloonParam =
×
UNCOV
466
            serde_aco::from_args(&balloon, &objects).context(error::ParseArg { arg: balloon })?;
×
467
        vm.add_virtio_dev("virtio-balloon", param)
×
468
            .context(error::CreateDevice)?;
×
469
    }
×
470

471
    #[cfg(target_os = "linux")]
UNCOV
472
    for ioas in args.vfio_ioas.into_iter() {
×
UNCOV
473
        let param: IoasParam =
×
NEW
474
            serde_aco::from_args(&ioas, &objects).context(error::ParseArg { arg: ioas })?;
×
NEW
475
        vm.add_vfio_ioas(param).context(error::CreateDevice)?;
×
476
    }
477
    #[cfg(target_os = "linux")]
NEW
478
    for (index, vfio) in args.vfio_cdev.into_iter().enumerate() {
×
479
        let param: CdevParam =
×
NEW
480
            serde_aco::from_args(&vfio, &objects).context(error::ParseArg { arg: vfio })?;
×
NEW
481
        vm.add_vfio_cdev(format!("vfio-{index}").into(), param)
×
NEW
482
            .context(error::CreateDevice)?;
×
483
    }
484

485
    #[cfg(target_os = "linux")]
486
    for container in args.vfio_container.into_iter() {
×
487
        let param: ContainerParam = serde_aco::from_args(&container, &objects)
×
488
            .context(error::ParseArg { arg: container })?;
×
489
        vm.add_vfio_container(param).context(error::CreateDevice)?;
×
490
    }
491
    #[cfg(target_os = "linux")]
UNCOV
492
    for (index, group) in args.vfio_group.into_iter().enumerate() {
×
UNCOV
493
        let param: GroupParam =
×
UNCOV
494
            serde_aco::from_args(&group, &objects).context(error::ParseArg { arg: group })?;
×
UNCOV
495
        vm.add_vfio_devs_in_group(&index.to_string(), param)
×
UNCOV
496
            .context(error::CreateDevice)?;
×
497
    }
498

UNCOV
499
    let payload = if let Some(fw) = args.firmware {
×
UNCOV
500
        Some(Payload {
×
UNCOV
501
            executable: fw,
×
UNCOV
502
            exec_type: ExecType::Firmware,
×
UNCOV
503
            initramfs: None,
×
UNCOV
504
            cmdline: None,
×
UNCOV
505
        })
×
UNCOV
506
    } else if let Some(kernel) = args.kernel {
×
UNCOV
507
        Some(Payload {
×
UNCOV
508
            exec_type: ExecType::Linux,
×
UNCOV
509
            executable: kernel,
×
UNCOV
510
            initramfs: args.initramfs,
×
UNCOV
511
            cmdline: args.cmdline,
×
UNCOV
512
        })
×
513
    } else {
514
        #[cfg(target_arch = "x86_64")]
UNCOV
515
        if let Some(pvh_kernel) = args.pvh {
×
UNCOV
516
            Some(Payload {
×
UNCOV
517
                executable: pvh_kernel,
×
UNCOV
518
                exec_type: ExecType::Pvh,
×
UNCOV
519
                initramfs: args.initramfs,
×
UNCOV
520
                cmdline: args.cmdline,
×
UNCOV
521
            })
×
522
        } else {
UNCOV
523
            None
×
524
        }
525
        #[cfg(not(target_arch = "x86_64"))]
UNCOV
526
        None
×
527
    };
UNCOV
528
    if let Some(payload) = payload {
×
UNCOV
529
        vm.add_payload(payload);
×
UNCOV
530
    }
×
531

UNCOV
532
    vm.boot().context(error::BootVm)?;
×
UNCOV
533
    vm.wait().context(error::WaitVm)?;
×
UNCOV
534
    Ok(())
×
UNCOV
535
}
×
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