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

mendersoftware / mender / 1019656555

28 Sep 2023 12:42PM UTC coverage: 78.153% (+0.009%) from 78.144%
1019656555

push

gitlab-ci

lluiscampos
feat: Implement `mender::update::http_resumer`

Implement class to download the Artifact, which will react to server
disconnections or other sorts of short read by scheduling new HTTP
requests with `Range` header.

See https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests for
an introduction to the feature, and read the specification for more
details.

The user calls _once_ `AsyncCall` with the header and body handlers, and
`DownloadResumerClient` will call back these handlers _once_ (each). At
the user's header handler, the Reader returned by `MakeBodyAsyncReader`
is a wrapper of the actual Body reader that is taking care of the resume
of the download, calling the `AsyncRead` finalized handler only after
the download is fully completed (with potential resumes) and or/the
resuming times out.

The validation of the `Content-Range` header and the cases for the unit
tests are heavily inspired by the legacy client. See:
* https://github.com/mendersoftware/mender/blob/<a class=hub.com/mendersoftware/mender/commit/d9010526d35d3ac861ea1e4210d36c2fef748ef8">d9010526d/client/update_resumer.go#L113
* https://github.com/mendersoftware/mender/blob/d9010526d35d3ac861ea1e4210d36c2fef748ef8/client/update_resumer_test.go#L197

Ticket: MEN-6498
Changelog: None

Signed-off-by: Lluis Campos <lluis.campos@northern.tech>

268 of 268 new or added lines in 5 files covered. (100.0%)

6965 of 8912 relevant lines covered (78.15%)

10386.16 hits per line

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

79.15
/mender-update/http_resumer/http_resumer.cpp
1
// Copyright 2023 Northern.tech AS
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
//        http://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
#include <mender-update/http_resumer.hpp>
16

17
#include <regex>
18

19
#include <common/common.hpp>
20
#include <common/expected.hpp>
21

22
namespace mender {
23
namespace update {
24
namespace http_resumer {
25

26
namespace common = mender::common;
27
namespace expected = mender::common::expected;
28
namespace http = mender::http;
29

30
// Represents the parts of a Content-Range HTTP header
31
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
32
struct RangeHeader {
33
        long long int range_start {0};
34
        long long int range_end {0};
35
        long long int size {0};
36
};
37
using ExpectedRangeHeader = expected::expected<RangeHeader, error::Error>;
38

39
// Parses the HTTP Content-Range header
40
// For an alternative implementation without regex dependency see:
41
// https://github.com/mendersoftware/mender/pull/1372/commits/ea711fc4dafa943266e9013fd6704da3d4518a27
42
ExpectedRangeHeader ParseRangeHeader(string header) {
41✔
43
        RangeHeader range_header {};
41✔
44

45
        std::regex content_range_regexp {R"(bytes\s+(\d+)\s?-\s?(\d+)\s?\/?\s?(\d+|\*)?)"};
82✔
46

47
        std::smatch range_matches;
82✔
48
        if (!regex_match(header, range_matches, content_range_regexp)) {
41✔
49
                return expected::unexpected(http::MakeError(
10✔
50
                        http::NoSuchHeaderError, "Invalid Content-Range returned from server: " + header));
20✔
51
        }
52

53
        auto exp_range_start = common::StringToLongLong(range_matches[1].str());
62✔
54
        auto exp_range_end = common::StringToLongLong(range_matches[2].str());
62✔
55
        if (!exp_range_start || !exp_range_end) {
31✔
56
                return expected::unexpected(http::MakeError(
×
57
                        http::NoSuchHeaderError, "Content-Range contains invalid number: " + header));
×
58
        }
59
        range_header.range_start = exp_range_start.value();
31✔
60
        range_header.range_end = exp_range_end.value();
31✔
61

62
        if (range_header.range_start > range_header.range_end) {
31✔
63
                return expected::unexpected(http::MakeError(
1✔
64
                        http::NoSuchHeaderError, "Invalid Content-Range returned from server: " + header));
2✔
65
        }
66

67
        if ((range_matches[3].matched) && (range_matches[3].str() != "*")) {
30✔
68
                auto exp_size = common::StringToLongLong(range_matches[3].str());
20✔
69
                if (!exp_size) {
20✔
70
                        return expected::unexpected(http::MakeError(
×
71
                                http::NoSuchHeaderError, "Content-Range contains invalid number: " + header));
×
72
                }
73
                range_header.size = exp_size.value();
20✔
74
        }
75

76
        return range_header;
30✔
77
}
78

79
class HeaderHandlerFunctor {
80
public:
81
        HeaderHandlerFunctor(weak_ptr<DownloadResumerClient> resumer) :
136✔
82
                resumer_client_ {resumer} {};
136✔
83

84
        void operator()(http::ExpectedIncomingResponsePtr exp_resp);
85

86
private:
87
        void HandleFirstResponse(
88
                const shared_ptr<DownloadResumerClient> &resumer_client,
89
                http::ExpectedIncomingResponsePtr exp_resp);
90
        void HandleNextResponse(
91
                const shared_ptr<DownloadResumerClient> &resumer_client,
92
                http::ExpectedIncomingResponsePtr exp_resp);
93

94
        weak_ptr<DownloadResumerClient> resumer_client_;
95
};
96

97
class BodyHandlerFunctor {
98
public:
99
        BodyHandlerFunctor(weak_ptr<DownloadResumerClient> resumer) :
136✔
100
                resumer_client_ {resumer} {};
136✔
101

102
        void operator()(http::ExpectedIncomingResponsePtr exp_resp);
103

104
private:
105
        weak_ptr<DownloadResumerClient> resumer_client_;
106
};
107

108
void HeaderHandlerFunctor::operator()(http::ExpectedIncomingResponsePtr exp_resp) {
135✔
109
        auto resumer_client = resumer_client_.lock();
270✔
110
        if (resumer_client) {
135✔
111
                if (resumer_client->resumer_state_->active_state == DownloadResumerActiveStatus::Resuming) {
135✔
112
                        HandleNextResponse(resumer_client, exp_resp);
53✔
113
                } else {
114
                        HandleFirstResponse(resumer_client, exp_resp);
82✔
115
                }
116
        }
117
}
135✔
118

119
void HeaderHandlerFunctor::HandleFirstResponse(
82✔
120
        const shared_ptr<DownloadResumerClient> &resumer_client,
121
        http::ExpectedIncomingResponsePtr exp_resp) {
122
        // The first response shall always call the user header callback. On resumable responses, we
123
        // create a our own incoming response and call the user header handler. On errors, we log a
124
        // warning and call the user handler with the original response
125

126
        if (!exp_resp) {
82✔
127
                resumer_client->logger_.Warning(exp_resp.error().String());
×
128
                resumer_client->CallUserHandler(exp_resp);
×
129
                return;
3✔
130
        }
131
        auto resp = exp_resp.value();
82✔
132

133
        if (resp->GetStatusCode() != mender::http::StatusOK) {
82✔
134
                // Non-resumable response
135
                resumer_client->CallUserHandler(exp_resp);
2✔
136
                return;
2✔
137
        }
138

139
        auto exp_header = resp->GetHeader("Content-Length");
160✔
140
        if (!exp_header || exp_header.value() == "0") {
80✔
141
                resumer_client->logger_.Warning("Response does not contain Content-Length header");
1✔
142
                resumer_client->CallUserHandler(exp_resp);
1✔
143
                return;
1✔
144
        }
145

146
        auto exp_length = common::StringToLongLong(exp_header.value());
79✔
147
        if (!exp_length || exp_length.value() < 0) {
79✔
148
                resumer_client->logger_.Warning(
×
149
                        "Content-Length contains invalid number: " + exp_header.value());
×
150
                resumer_client->CallUserHandler(exp_resp);
×
151
                return;
×
152
        }
153

154
        // Resumable response
155
        resumer_client->resumer_state_->active_state = DownloadResumerActiveStatus::Resuming;
79✔
156
        resumer_client->resumer_state_->offset = 0;
79✔
157
        resumer_client->resumer_state_->content_length = exp_length.value();
79✔
158

159
        // Prepare a modified response and call user handler
160
        resumer_client->response_.reset(new http::IncomingResponse(*resumer_client, resp->cancelled_));
79✔
161
        resumer_client->response_->status_code_ = resp->GetStatusCode();
79✔
162
        resumer_client->response_->status_message_ = resp->GetStatusMessage();
79✔
163
        resumer_client->response_->headers_ = resp->GetHeaders();
79✔
164
        resumer_client->CallUserHandler(resumer_client->response_);
79✔
165
}
166

167
void HeaderHandlerFunctor::HandleNextResponse(
53✔
168
        const shared_ptr<DownloadResumerClient> &resumer_client,
169
        http::ExpectedIncomingResponsePtr exp_resp) {
170
        // If an error occurs has already occurred, schedule the next AsyncCall directly
171
        // If an error occurs during handling here, cancel the resuming and call the user handler.
172
        if (!exp_resp) {
53✔
173
                resumer_client->logger_.Warning(exp_resp.error().String());
11✔
174

175
                auto err = resumer_client->ScheduleNextResumeRequest();
11✔
176
                if (err != error::NoError) {
11✔
177
                        resumer_client->logger_.Error(err.String());
1✔
178
                        resumer_client->Cancel();
1✔
179
                        resumer_client->CallUserHandler(expected::unexpected(err));
1✔
180
                }
181
                return;
11✔
182
        }
183
        auto resp = exp_resp.value();
42✔
184

185
        auto exp_content_range = resp->GetHeader("Content-Range").and_then(ParseRangeHeader);
84✔
186
        if (!exp_content_range) {
42✔
187
                resumer_client->logger_.Error(exp_content_range.error().String());
12✔
188
                resumer_client->Cancel();
12✔
189
                resumer_client->CallUserHandler(expected::unexpected(exp_content_range.error()));
12✔
190
                return;
12✔
191
        }
192

193
        auto content_range = exp_content_range.value();
30✔
194
        if (content_range.size != 0
60✔
195
                && content_range.size != resumer_client->resumer_state_->content_length) {
30✔
196
                auto size_changed_err = http::MakeError(
197
                        http::DownloadResumerError,
198
                        "Size of artifact changed after download was resumed (expected "
199
                                + to_string(resumer_client->resumer_state_->content_length) + ", got "
4✔
200
                                + to_string(content_range.size) + ")");
6✔
201
                resumer_client->logger_.Error(size_changed_err.String());
2✔
202
                resumer_client->Cancel();
2✔
203
                resumer_client->CallUserHandler(expected::unexpected(size_changed_err));
2✔
204
                return;
2✔
205
        }
206

207
        if ((content_range.range_end != resumer_client->resumer_state_->content_length - 1)
28✔
208
                || (content_range.range_start != resumer_client->resumer_state_->offset)) {
28✔
209
                auto bad_range_err = http::MakeError(
210
                        http::DownloadResumerError,
211
                        "HTTP server returned an different range than requested. Requested "
212
                                + to_string(resumer_client->resumer_state_->offset) + "-"
4✔
213
                                + to_string(resumer_client->resumer_state_->content_length - 1) + ", got "
8✔
214
                                + to_string(content_range.range_start) + "-" + to_string(content_range.range_end));
8✔
215
                resumer_client->logger_.Error(bad_range_err.String());
2✔
216
                resumer_client->Cancel();
2✔
217
                resumer_client->CallUserHandler(expected::unexpected(bad_range_err));
2✔
218
                return;
2✔
219
        }
220

221
        // Get the reader for the new response
222
        auto exp_reader = resumer_client->client_.MakeBodyAsyncReader(resp);
26✔
223
        if (!exp_reader) {
26✔
224
                auto bad_range_err = exp_reader.error().WithContext("cannot get the reader after resume");
×
225
                resumer_client->logger_.Error(bad_range_err.String());
×
226
                resumer_client->Cancel();
×
227
                resumer_client->CallUserHandler(expected::unexpected(bad_range_err));
×
228
                return;
×
229
        }
230
        // Update the inner reader of the user reader
231
        resumer_client->resumer_reader_->inner_reader_ = exp_reader.value();
26✔
232

233
        // Resume reading reusing last user data (start, end, handler)
234
        auto err = resumer_client->resumer_reader_->AsyncReadResume();
26✔
235
        if (err != error::NoError) {
26✔
236
                auto bad_read_err = err.WithContext("error reading after resume");
×
237
                resumer_client->logger_.Error(bad_read_err.String());
×
238
                resumer_client->Cancel();
×
239
                resumer_client->CallUserHandler(expected::unexpected(bad_read_err));
×
240
                return;
×
241
        }
242
}
243

244
void BodyHandlerFunctor::operator()(http::ExpectedIncomingResponsePtr exp_resp) {
124✔
245
        auto resumer_client = resumer_client_.lock();
124✔
246
        if (!resumer_client) {
124✔
247
                return;
51✔
248
        }
249

250
        if (*resumer_client->cancelled_) {
73✔
251
                resumer_client->CallUserHandler(exp_resp);
19✔
252
                return;
19✔
253
        }
254

255
        if (resumer_client->resumer_state_->active_state == DownloadResumerActiveStatus::Inactive) {
54✔
256
                resumer_client->CallUserHandler(exp_resp);
2✔
257
                return;
2✔
258
        }
259

260
        // We resume the download if either:
261
        // * there is any error or
262
        // * successful read with status code Partial Content and there is still data missing
263
        const bool is_range_response =
264
                exp_resp && exp_resp.value()->GetStatusCode() == mender::http::StatusPartialContent;
52✔
265
        const bool is_data_missing =
266
                resumer_client->resumer_state_->offset < resumer_client->resumer_state_->content_length;
52✔
267
        if (!exp_resp || (is_range_response && is_data_missing)) {
52✔
268
                if (!exp_resp) {
43✔
269
                        resumer_client->logger_.Info(
86✔
270
                                "Will try to resume after error " + exp_resp.error().String());
86✔
271
                }
272

273
                auto err = resumer_client->ScheduleNextResumeRequest();
43✔
274
                if (err != error::NoError) {
43✔
275
                        resumer_client->logger_.Error(err.String());
×
276
                        resumer_client->Cancel();
×
277
                        resumer_client->CallUserHandler(expected::unexpected(err));
×
278
                        return;
×
279
                }
280
        } else {
281
                // Update headers with the last received server response. When resuming has taken place,
282
                // the user will get different headers on header and body handlers, representing (somehow)
283
                // what the resumer has been doing in its behalf.
284
                auto resp = exp_resp.value();
9✔
285
                resumer_client->response_->status_code_ = resp->GetStatusCode();
9✔
286
                resumer_client->response_->status_message_ = resp->GetStatusMessage();
9✔
287
                resumer_client->response_->headers_ = resp->GetHeaders();
9✔
288

289
                // Finished, call the user handler \o/
290
                resumer_client->logger_.Debug("Download resumed and completed successfully");
9✔
291
                resumer_client->DoCancel();
9✔
292
                resumer_client->CallUserHandler(resumer_client->response_);
9✔
293
        }
294
}
295

296
void DownloadResumerAsyncReader::Cancel() {
×
297
        if (!*cancelled_) {
×
298
                inner_reader_->Cancel();
×
299
        }
300
}
×
301

302
error::Error DownloadResumerAsyncReader::AsyncRead(
2,032✔
303
        vector<uint8_t>::iterator start, vector<uint8_t>::iterator end, io::AsyncIoHandler handler) {
304
        auto resumer_client = resumer_client_.lock();
4,064✔
305
        if (!resumer_client || *cancelled_) {
2,032✔
306
                return error::MakeError(
307
                        error::ProgrammingError,
308
                        "DownloadResumerAsyncReader::AsyncRead called after stream is destroyed");
×
309
        }
310
        // Save user parameters for further resumes of the body read
311
        resumer_client->last_read_ = {.start = start, .end = end, .handler = handler};
2,032✔
312
        return AsyncReadResume();
2,032✔
313
}
314

315
error::Error DownloadResumerAsyncReader::AsyncReadResume() {
2,058✔
316
        auto resumer_client = resumer_client_.lock();
4,116✔
317
        if (!resumer_client) {
2,058✔
318
                return error::MakeError(
319
                        error::ProgrammingError,
320
                        "DownloadResumerAsyncReader::AsyncReadResume called after client is destroyed");
×
321
        }
322
        return inner_reader_->AsyncRead(
2,058✔
323
                resumer_client->last_read_.start,
2,058✔
324
                resumer_client->last_read_.end,
2,058✔
325
                [this](io::ExpectedSize result) {
8,185✔
326
                        if (!result) {
2,057✔
327
                                inner_reader_.reset();
43✔
328

329
                                logger_.Warning(
43✔
330
                                        "Reading error, a new request will be re-scheduled. "
331
                                        + result.error().String());
86✔
332
                        } else {
333
                                resumer_state_->offset += result.value();
2,014✔
334
                                logger_.Debug("read " + to_string(result.value()) + " bytes");
2,014✔
335
                                auto resumer_client = resumer_client_.lock();
4,028✔
336
                                if (resumer_client) {
2,014✔
337
                                        resumer_client->last_read_.handler(result);
2,014✔
338
                                } else {
339
                                        logger_.Error(
×
340
                                                "AsyncRead finish handler called after resumer client has been destroyed.");
×
341
                                }
342
                        }
343
                });
6,173✔
344
}
345

346
DownloadResumerClient::DownloadResumerClient(
×
347
        const http::ClientConfig &config, events::EventLoop &event_loop) :
119✔
348
        resumer_state_ {make_shared<DownloadResumerClientState>()},
349
        client_(config, event_loop, "http_resumer:client"),
350
        logger_ {"http_resumer:client"},
351
        cancelled_ {make_shared<bool>(true)},
×
352
        retry_ {
353
                .backoff = http::ExponentialBackoff(chrono::minutes(1), 10),
×
354
                .wait_timer = events::Timer(event_loop)} {
×
355
}
×
356

357
DownloadResumerClient::~DownloadResumerClient() {
×
358
        if (!*cancelled_) {
×
359
                logger_.Warning("DownloadResumerClient destroyed while request is still active!");
×
360
        }
361
        client_.Cancel();
×
362
}
×
363

364
error::Error DownloadResumerClient::AsyncCall(
83✔
365
        http::OutgoingRequestPtr req,
366
        http::ResponseHandler user_header_handler,
367
        http::ResponseHandler user_body_handler) {
368
        HeaderHandlerFunctor resumer_header_handler {shared_from_this()};
249✔
369
        BodyHandlerFunctor resumer_body_handler {shared_from_this()};
249✔
370

371
        user_request_ = req;
83✔
372
        user_header_handler_ = user_header_handler;
83✔
373
        user_body_handler_ = user_body_handler;
83✔
374

375
        if (!*cancelled_) {
83✔
376
                return error::Error(
377
                        make_error_condition(errc::operation_in_progress), "HTTP call already ongoing");
2✔
378
        }
379

380
        *cancelled_ = false;
82✔
381
        retry_.backoff.Reset();
82✔
382
        resumer_state_->active_state = DownloadResumerActiveStatus::Inactive;
82✔
383
        resumer_state_->user_handlers_state = DownloadResumerUserHandlersStatus::None;
82✔
384
        return client_.AsyncCall(req, resumer_header_handler, resumer_body_handler);
82✔
385
}
386

387
io::ExpectedAsyncReaderPtr DownloadResumerClient::MakeBodyAsyncReader(
79✔
388
        http::IncomingResponsePtr resp) {
389
        auto exp_reader = client_.MakeBodyAsyncReader(resp);
158✔
390
        if (!exp_reader) {
79✔
391
                return exp_reader;
1✔
392
        }
393
        resumer_reader_ = make_shared<DownloadResumerAsyncReader>(
78✔
394
                exp_reader.value(), resumer_state_, cancelled_, shared_from_this());
156✔
395
        return resumer_reader_;
78✔
396
}
397

398
http::OutgoingRequestPtr DownloadResumerClient::RemainingRangeRequest() const {
53✔
399
        auto range_req = make_shared<http::OutgoingRequest>(*user_request_);
53✔
400
        range_req->SetHeader(
106✔
401
                "Range",
402
                "bytes=" + to_string(resumer_state_->offset) + "-"
106✔
403
                        + to_string(resumer_state_->content_length - 1));
212✔
404
        return range_req;
53✔
405
};
406

407
error::Error DownloadResumerClient::ScheduleNextResumeRequest() {
54✔
408
        auto exp_interval = retry_.backoff.NextInterval();
108✔
409
        if (!exp_interval) {
54✔
410
                return http::MakeError(
411
                        http::DownloadResumerError,
412
                        "Giving up on resuming the download: " + exp_interval.error().String());
2✔
413
        }
414

415
        auto interval = exp_interval.value();
53✔
416
        logger_.Info(
53✔
417
                "Resuming download after " + to_string(chrono::milliseconds(interval).count() / 1000)
106✔
418
                + " seconds");
106✔
419

420
        HeaderHandlerFunctor resumer_next_header_handler {shared_from_this()};
159✔
421
        BodyHandlerFunctor resumer_next_body_handler {shared_from_this()};
159✔
422

423
        retry_.wait_timer.AsyncWait(
53✔
424
                interval, [this, resumer_next_header_handler, resumer_next_body_handler](error::Error err) {
159✔
425
                        if (err != error::NoError) {
53✔
426
                                auto err_user = http::MakeError(
427
                                        http::DownloadResumerError, "Unexpected error in wait timer: " + err.String());
×
428
                                logger_.Error(err_user.String());
×
429
                                Cancel();
×
430
                                CallUserHandler(expected::unexpected(err_user));
×
431
                                return;
×
432
                        }
433

434
                        auto next_call_err = client_.AsyncCall(
435
                                RemainingRangeRequest(), resumer_next_header_handler, resumer_next_body_handler);
159✔
436
                        if (next_call_err != error::NoError) {
53✔
437
                                // Schedule once more
438
                                auto err = ScheduleNextResumeRequest();
×
439
                                if (err != error::NoError) {
×
440
                                        logger_.Error(err.String());
×
441
                                        Cancel();
×
442
                                        CallUserHandler(expected::unexpected(err));
×
443
                                }
444
                        }
445
                });
106✔
446

447
        return error::NoError;
53✔
448
}
449

450
void DownloadResumerClient::CallUserHandler(http::ExpectedIncomingResponsePtr exp_resp) {
129✔
451
        if (resumer_state_->user_handlers_state == DownloadResumerUserHandlersStatus::None) {
129✔
452
                resumer_state_->user_handlers_state =
82✔
453
                        DownloadResumerUserHandlersStatus::HeaderHandlerCalled;
454
                user_header_handler_(exp_resp);
82✔
455
        } else if (
47✔
456
                resumer_state_->user_handlers_state
47✔
457
                == DownloadResumerUserHandlersStatus::HeaderHandlerCalled) {
47✔
458
                resumer_state_->user_handlers_state = DownloadResumerUserHandlersStatus::BodyHandlerCalled;
31✔
459
                user_body_handler_(exp_resp);
31✔
460
        } else {
461
                string msg;
16✔
462
                if (!exp_resp) {
16✔
463
                        msg = "error: " + exp_resp.error().String();
16✔
464
                } else {
465
                        auto &resp = exp_resp.value();
×
466
                        msg = "response: " + to_string(resp->GetStatusCode()) + " " + resp->GetStatusMessage();
×
467
                }
468
                logger_.Warning("Cannot call any user handler with " + msg);
16✔
469
        }
470
}
129✔
471

472
void DownloadResumerClient::Cancel() {
20✔
473
        DoCancel();
20✔
474
        client_.Cancel();
20✔
475
};
20✔
476

477
void DownloadResumerClient::DoCancel() {
29✔
478
        // Set cancel state and then make a new one. Those who are interested should have their own
479
        // pointer to the old one.
480
        *cancelled_ = true;
29✔
481
        cancelled_ = make_shared<bool>(true);
29✔
482
};
29✔
483

484
} // namespace http_resumer
485
} // namespace update
486
} // namespace mender
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

© 2025 Coveralls, Inc