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

kit-data-manager / pit-service / #473

29 Jan 2025 02:18PM UTC coverage: 76.81%. First build
#473

Pull #271

github

web-flow
Merge 61c449f25 into 4c17b004d
Pull Request #271: Separate resolver from the configured system

33 of 35 new or added lines in 4 files covered. (94.29%)

944 of 1229 relevant lines covered (76.81%)

0.77 hits per line

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

31.8
/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java
1
package edu.kit.datamanager.pit.pidsystem.impl;
2

3
import java.io.IOException;
4
import java.nio.charset.StandardCharsets;
5
import java.security.PrivateKey;
6
import java.security.spec.InvalidKeySpecException;
7
import java.util.ArrayList;
8
import java.util.Arrays;
9
import java.util.Collection;
10
import java.util.List;
11
import java.util.Map;
12
import java.util.Optional;
13
import java.util.Map.Entry;
14
import java.util.stream.Collectors;
15
import java.util.stream.Stream;
16

17
import edu.kit.datamanager.pit.recordModifiers.RecordModifier;
18
import jakarta.annotation.PostConstruct;
19
import jakarta.validation.constraints.NotNull;
20

21
import org.apache.commons.lang3.stream.Streams;
22
import org.slf4j.Logger;
23
import org.slf4j.LoggerFactory;
24
import org.springframework.beans.factory.annotation.Autowired;
25
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
26
import org.springframework.stereotype.Component;
27
import edu.kit.datamanager.pit.common.ExternalServiceException;
28
import edu.kit.datamanager.pit.common.InvalidConfigException;
29
import edu.kit.datamanager.pit.common.PidAlreadyExistsException;
30
import edu.kit.datamanager.pit.common.PidNotFoundException;
31
import edu.kit.datamanager.pit.common.PidUpdateException;
32
import edu.kit.datamanager.pit.common.RecordValidationException;
33
import edu.kit.datamanager.pit.configuration.HandleCredentials;
34
import edu.kit.datamanager.pit.configuration.HandleProtocolProperties;
35
import edu.kit.datamanager.pit.domain.PIDRecord;
36
import edu.kit.datamanager.pit.domain.PIDRecordEntry;
37
import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem;
38
import net.handle.api.HSAdapter;
39
import net.handle.api.HSAdapterFactory;
40
import net.handle.apps.batch.BatchUtil;
41
import net.handle.hdllib.Common;
42
import net.handle.hdllib.HandleException;
43
import net.handle.hdllib.HandleResolver;
44
import net.handle.hdllib.HandleValue;
45
import net.handle.hdllib.PublicKeyAuthenticationInfo;
46
import net.handle.hdllib.SiteInfo;
47
import net.handle.hdllib.Util;
48

49
/**
50
 * Uses the official java library to interact with the handle system using the
51
 * handle protocol.
52
 */
53
@Component
54
@ConditionalOnBean(HandleProtocolProperties.class)
55
public class HandleProtocolAdapter implements IIdentifierSystem {
56

57
    private static final Logger LOG = LoggerFactory.getLogger(HandleProtocolAdapter.class);
1✔
58

59
    private static final byte[][][] BLACKLIST_NONTYPE_LISTS = {
1✔
60
            Common.SITE_INFO_AND_SERVICE_HANDLE_INCL_PREFIX_TYPES,
61
            Common.DERIVED_PREFIX_SITE_AND_SERVICE_HANDLE_TYPES,
62
            Common.SERVICE_HANDLE_TYPES,
63
            Common.LOCATION_AND_ADMIN_TYPES,
64
            Common.SECRET_KEY_TYPES,
65
            Common.PUBLIC_KEY_TYPES,
66
            // Common.STD_TYPES, // not using because of URL and EMAIL
67
            {
68
                    // URL and EMAIL might contain valuable information and can be considered
69
                    // non-technical.
70
                    // Common.STD_TYPE_URL,
71
                    // Common.STD_TYPE_EMAIL,
72
                    Common.STD_TYPE_HSADMIN,
73
                    Common.STD_TYPE_HSALIAS,
74
                    Common.STD_TYPE_HSSITE,
75
                    Common.STD_TYPE_HSSITE6,
76
                    Common.STD_TYPE_HSSERV,
77
                    Common.STD_TYPE_HSSECKEY,
78
                    Common.STD_TYPE_HSPUBKEY,
79
                    Common.STD_TYPE_HSVALLIST,
80
            }
81
    };
82

83
    private static final String SERVICE_NAME_HANDLE = "Handle System";
84

85
    // Properties specific to this adapter.
86
    @Autowired
87
    final private HandleProtocolProperties props;
88
    // Handle Protocol implementation
89
    private HSAdapter client;
90
    // indicates if the adapter can modify and create PIDs or just resolve them.
91
    private boolean isAdminMode = false;
1✔
92
    // the value that is appended to every new record.
93
    private HandleValue adminValue;
94

95
    // For testing
96
    public HandleProtocolAdapter(HandleProtocolProperties props) {
1✔
97
        this.props = props;
1✔
98
    }
1✔
99

100
    /**
101
     * Initializes internal classes.
102
     * We use this method with the @PostConstruct annotation to run it
103
     * after the constructor and after springs autowiring is done properly
104
     * to make sure that all properties are already autowired.
105
     * 
106
     * @throws HandleException        if a handle system error occurs.
107
     * @throws InvalidConfigException if the configuration is invalid, e.g. a path
108
     *                                does not lead to a file.
109
     * @throws IOException            if the private key file can not be read.
110
     */
111
    @PostConstruct
112
    public void init() throws InvalidConfigException, HandleException, IOException {
113
        LOG.info("Using PID System 'Handle'");
1✔
114
        this.isAdminMode = props.getCredentials() != null;
1✔
115

116
        if (!this.isAdminMode) {
1✔
117
            LOG.warn("No credentials found. Starting Handle Adapter with no administrative privileges.");
1✔
118
            this.client = HSAdapterFactory.newInstance();
1✔
119

120
        } else {
121
            HandleCredentials credentials = props.getCredentials();
×
122
            // Check if key file is plausible, throw exceptions if something is wrong.
123
            byte[] privateKey = credentials.getPrivateKeyFileContent();
×
124
            byte[] passphrase = credentials.getPrivateKeyPassphraseAsBytes();
×
125
            LOG.debug("Logging in with user {}", credentials.getUserHandle());
×
126
            this.client = HSAdapterFactory.newInstance(
×
127
                    credentials.getUserHandle(),
×
128
                    credentials.getPrivateKeyIndex(),
×
129
                    privateKey,
130
                    passphrase // "use null for unencrypted keys"
131
            );
132
            HandleIndex indexManager = new HandleIndex();
×
133
            this.adminValue = this.client.createAdminValue(
×
134
                    props.getCredentials().getUserHandle(),
×
135
                    props.getCredentials().getPrivateKeyIndex(),
×
136
                    indexManager.getHsAdminIndex());
×
137
        }
138
    }
1✔
139

140
    @Override
141
    public Optional<String> getPrefix() {
142
        if (this.isAdminMode) {
1✔
143
            return Optional.ofNullable(this.props.getCredentials()).map(HandleCredentials::getHandleIdentifierPrefix);
×
144
        } else {
145
            return Optional.empty();
1✔
146
        }
147
    }
148

149
    @Override
150
    public boolean isPidRegistered(final String pid) throws ExternalServiceException {
151
        HandleValue[] recordProperties;
152
        try {
153
            recordProperties = this.client.resolveHandle(pid, null, null);
1✔
154
        } catch (HandleException e) {
1✔
155
            if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) {
1✔
156
                return false;
1✔
157
            } else {
158
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
159
            }
160
        }
1✔
161
        return recordProperties != null && recordProperties.length > 0;
1✔
162
    }
163

164
    @Override
165
    public PIDRecord queryPid(final String pid) throws PidNotFoundException, ExternalServiceException {
166
        Collection<HandleValue> allValues = this.queryAllHandleValues(pid);
1✔
167
        if (allValues.isEmpty()) {
1✔
168
            return null;
×
169
        }
170
        Collection<HandleValue> recordProperties = Streams.failableStream(allValues.stream())
1✔
171
                .filter(value -> !isHandleInternalValue(value))
1✔
172
                .collect(Collectors.toList());
1✔
173
        return new PIDRecord(recordProperties).withPID(pid);
1✔
174
    }
175

176
    @NotNull
177
    protected Collection<HandleValue> queryAllHandleValues(final String pid) throws PidNotFoundException, ExternalServiceException {
178
        try {
179
            HandleValue[] values = this.client.resolveHandle(pid, null, null);
1✔
180
            return Stream.of(values)
1✔
181
                    .collect(Collectors.toCollection(ArrayList::new));
1✔
182
        } catch (HandleException e) {
1✔
183
            if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) {
1✔
184
                throw new PidNotFoundException(pid, e);
1✔
185
            } else {
186
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
187
            }
188
        }
189
    }
190

191
    @Override
192
    public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException {
193
        // Add admin value for configured user only
194
        // TODO add options to add additional adminValues e.g. for user lists?
195
        ArrayList<HandleValue> admin = new ArrayList<>();
×
196
        admin.add(this.adminValue);
×
197
        PIDRecord preparedRecord = pidRecord;
×
198
        for (RecordModifier modifier : this.props.getConfiguredModifiers()) {
×
199
            preparedRecord = modifier.apply(preparedRecord);
×
200
        }
×
201
        ArrayList<HandleValue> futurePairs = this.handleValuesFrom(preparedRecord, Optional.of(admin));
×
202

203
        HandleValue[] futurePairsArray = futurePairs.toArray(new HandleValue[] {});
×
204

205
        try {
206
            this.client.createHandle(preparedRecord.getPid(), futurePairsArray);
×
207
        } catch (HandleException e) {
×
208
            if (e.getCode() == HandleException.HANDLE_ALREADY_EXISTS) {
×
209
                // Should not happen as this has to be checked on the REST handler level.
210
                throw new PidAlreadyExistsException(preparedRecord.getPid());
×
211
            } else {
212
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
213
            }
214
        }
×
215
        return preparedRecord.getPid();
×
216
    }
217

218
    @Override
219
    public boolean updatePid(final PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException {
220
        if (!this.isValidPID(pidRecord.getPid())) {
×
221
            return false;
×
222
        }
223
        PIDRecord preparedRecord = pidRecord;
×
224
        for (RecordModifier modifier : this.props.getConfiguredModifiers()) {
×
225
            preparedRecord = modifier.apply(preparedRecord);
×
226
        }
×
227
        // We need to override the old record as the user has no possibility to update
228
        // single values, and matching is hard.
229
        // The API expects the user to insert what the result should be. Due to the
230
        // Handle Protocol client available
231
        // functions and the way the handle system works with indices (basically value
232
        // identifiers), we use this approach:
233
        // 1) from the old values, take all we want to keep (handle internal values, technical stuff).
234
        // 2) together with the user-given record, merge "valuesToKeep" to a list of
235
        // values with unique indices. Now we have exactly the representation we want.
236
        // But: we cannot tell the handle API what we want, we have to declare how to do it.
237
        // This is why we need two more steps:
238
        // 3) see (by index) which values have to be added, deleted, or updated.
239
        // 4) then add, update, delete in this order. Why this order? We could also remove everything
240
        // at first and then add everything we want, but this would require more actions on the server
241
        // side. And, deleting everything would also delete access control information. So, the safe
242
        // way to do it, is to add things which do not exist yet, update what needs to be updated,
243
        // and in the end remove what needs to be removed (usually nothing!).
244

245
        // index value
246
        Collection<HandleValue> oldHandleValues = this.queryAllHandleValues(preparedRecord.getPid());
×
247
        Map<Integer, HandleValue> recordOld = oldHandleValues.stream()
×
248
                .collect(Collectors.toMap(HandleValue::getIndex, v -> v));
×
249
        // 1)
250
        List<HandleValue> valuesToKeep = oldHandleValues.stream()
×
NEW
251
                .filter(HandleProtocolAdapter::isHandleInternalValue)
×
252
                .collect(Collectors.toList());
×
253

254
        // 2) Merge requested record and things we want to keep.
255
        Map<Integer, HandleValue> recordNew = handleValuesFrom(preparedRecord, Optional.of(valuesToKeep))
×
256
                .stream()
×
257
                .collect(Collectors.toMap(HandleValue::getIndex, v -> v));
×
258

259
        try {
260
            // 3)
261
            HandleDiff diff = new HandleDiff(recordOld, recordNew);
×
262
            // 4)
263
            if (diff.added().length > 0) {
×
264
                this.client.addHandleValues(preparedRecord.getPid(), diff.added());
×
265
            }
266
            if (diff.updated().length > 0) {
×
267
                this.client.updateHandleValues(preparedRecord.getPid(), diff.updated());
×
268
            }
269
            if (diff.removed().length > 0) {
×
270
                this.client.deleteHandleValues(preparedRecord.getPid(), diff.removed());
×
271
            }
272
        } catch (HandleException e) {
×
273
            if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) {
×
274
                return false;
×
275
            } else {
276
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
277
            }
278
        } catch (Exception e) {
×
279
            throw new RuntimeException("Implementation error in calculating record difference. PLEASE REPORT!", e);
×
280
        }
×
281
        return true;
×
282
    }
283

284
    @Override
285
    public boolean deletePid(final String pid) throws ExternalServiceException {
286
        try {
287
            this.client.deleteHandle(pid);
×
288
        } catch (HandleException e) {
×
289
            if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) {
×
290
                return false;
×
291
            } else {
292
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
293
            }
294
        }
×
295
        return true;
×
296
    }
297

298
    @Override
299
    public Collection<String> resolveAllPidsOfPrefix() throws ExternalServiceException, InvalidConfigException {
300
        HandleCredentials handleCredentials = this.props.getCredentials();
×
301
        if (handleCredentials == null) {
×
302
            throw new InvalidConfigException("No credentials for handle protocol configured.");
×
303
        }
304

305
        PrivateKey key;
306
        {
307
            byte[] privateKeyBytes;
308
            try {
309
                privateKeyBytes = handleCredentials.getPrivateKeyFileContent();
×
310
            } catch (IOException e) {
×
311
                throw new InvalidConfigException("Could not read private key file content.");
×
312
            }
×
313
            if (privateKeyBytes == null || privateKeyBytes.length == 0) {
×
314
                throw new InvalidConfigException("Private Key is empty!");
×
315
            }
316
            byte[] passphrase = handleCredentials.getPrivateKeyPassphraseAsBytes();
×
317
            byte[] privateKeyDecrypted;
318
            // decrypt the private key using the passphrase/cypher
319
            try {
320
                privateKeyDecrypted = Util.decrypt(privateKeyBytes, passphrase);
×
321
            } catch (Exception e) {
×
322
                throw new InvalidConfigException("Private key decryption failed: " + e.getMessage());
×
323
            }
×
324
            try {
325
                key = Util.getPrivateKeyFromBytes(privateKeyDecrypted, 0);
×
326
            } catch (HandleException | InvalidKeySpecException e) {
×
327
                throw new InvalidConfigException("Private key conversion failed: " + e.getMessage());
×
328
            }
×
329
        }
330

331
        PublicKeyAuthenticationInfo auth = new PublicKeyAuthenticationInfo(
×
332
                Util.encodeString(handleCredentials.getUserHandle()),
×
333
                handleCredentials.getPrivateKeyIndex(),
×
334
                key);
335

336
        HandleResolver resolver = new HandleResolver();
×
337
        SiteInfo site;
338
        {
339
            HandleValue[] prefixValues;
340
            try {
341
                prefixValues = resolver.resolveHandle(handleCredentials.getHandleIdentifierPrefix());
×
342
                site = BatchUtil.getFirstPrimarySiteFromHserv(prefixValues, resolver);
×
343
            } catch (HandleException e) {
×
344
                throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
345
            }
×
346
        }
347

348
        String prefix = handleCredentials.getHandleIdentifierPrefix();
×
349
        try {
350
            return BatchUtil.listHandles(prefix, site, resolver, auth);
×
351
        } catch (HandleException e) {
×
352
            throw new ExternalServiceException(SERVICE_NAME_HANDLE, e);
×
353
        }
354
    }
355

356
    /**
357
     * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the
358
     * inverse method to `pidRecordFrom`.
359
     * 
360
     * @param pidRecord the record containing values to convert / extract.
361
     * @param toMerge   an optional list to merge the result with.
362
     * @return HandleValues containing the same key-value pairs as the given record,
363
     *         but e.g. without the name.
364
     */
365
    protected ArrayList<HandleValue> handleValuesFrom(
366
            final PIDRecord pidRecord,
367
            final Optional<List<HandleValue>> toMerge)
368
        {
369
        ArrayList<Integer> skippingIndices = new ArrayList<>();
×
370
        ArrayList<HandleValue> result = new ArrayList<>();
×
371
        if (toMerge.isPresent()) {
×
372
            for (HandleValue v : toMerge.get()) {
×
373
                result.add(v);
×
374
                skippingIndices.add(v.getIndex());
×
375
            }
×
376
        }
377
        HandleIndex index = new HandleIndex().skipping(skippingIndices);
×
378
        Map<String, List<PIDRecordEntry>> entries = pidRecord.getEntries();
×
379

380
        for (Entry<String, List<PIDRecordEntry>> entry : entries.entrySet()) {
×
381
            for (PIDRecordEntry val : entry.getValue()) {
×
382
                String key = val.getKey();
×
383
                HandleValue hv = new HandleValue();
×
384
                int i = index.nextIndex();
×
385
                hv.setIndex(i);
×
386
                hv.setType(key.getBytes(StandardCharsets.UTF_8));
×
387
                hv.setData(val.getValue().getBytes(StandardCharsets.UTF_8));
×
388
                result.add(hv);
×
389
                LOG.debug("Entry: ({}) {} <-> {}", i, key, val);
×
390
            }
×
391
        }
×
392
        assert result.size() >= pidRecord.getEntries().keySet().size();
×
393
        return result;
×
394
    }
395

396
    protected static class HandleIndex {
×
397
        // handle record indices start at 1
398
        private int index = 1;
×
399
        private List<Integer> skipping = new ArrayList<>();
×
400

401
        public final int nextIndex() {
402
            int result = index;
×
403
            index += 1;
×
404
            if (index == this.getHsAdminIndex() || skipping.contains(index)) {
×
405
                index += 1;
×
406
            }
407
            return result;
×
408
        }
409

410
        public HandleIndex skipping(List<Integer> skipThose) {
411
            this.skipping = skipThose;
×
412
            return this;
×
413
        }
414

415
        public final int getHsAdminIndex() {
416
            return 100;
×
417
        }
418
    }
419

420
    /**
421
     * Returns true if the PID is valid according to the following criteria:
422
     * - PID is valid according to isIdentifierRegistered
423
     * - If a generator prefix is set, the PID is expedted to have this prefix.
424
     * 
425
     * @param pid the identifier / PID to check.
426
     * @return true if PID is registered (and if has the generatorPrefix, if it
427
     *         exists).
428
     */
429
    protected boolean isValidPID(final String pid) {
430
        boolean isAuthMode = this.props.getCredentials() != null;
×
431
        if (isAuthMode && !pid.startsWith(this.props.getCredentials().getHandleIdentifierPrefix())) {
×
432
            return false;
×
433
        }
434
        try {
435
            if (!this.isPidRegistered(pid)) {
×
436
                return false;
×
437
            }
438
        } catch (ExternalServiceException e) {
×
439
            return false;
×
440
        }
×
441
        return true;
×
442
    }
443

444
    /**
445
     * Checks if a given value is considered an "internal" or "handle-native" value.
446
     * <p>
447
     * This may be used to filter out administrative information from a PID record.
448
     * 
449
     * @param v the value to check.
450
     * @return true, if the value is conidered "handle-native".
451
     */
452
    public static boolean isHandleInternalValue(HandleValue v) {
453
        boolean isInternalValue = false;
1✔
454
        for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) {
1✔
455
            for (byte[] typeCode : typeList) {
1✔
456
                isInternalValue = isInternalValue || Arrays.equals(v.getType(), typeCode);
1✔
457
            }
458
        }
459
        return isInternalValue;
1✔
460
    }
461

462
    /**
463
     * Given two Value Maps, it splits the values in those which have been added,
464
     * updated or removed.
465
     * Using this lists, an update can be applied to the old record, to bring it to
466
     * the state of the new record.
467
     */
468
    protected static class HandleDiff {
469
        private final Collection<HandleValue> toAdd = new ArrayList<>();
1✔
470
        private final Collection<HandleValue> toUpdate = new ArrayList<>();
1✔
471
        private final Collection<HandleValue> toRemove = new ArrayList<>();
1✔
472

473
        HandleDiff(
474
            final Map<Integer, HandleValue> recordOld,
475
            final Map<Integer, HandleValue> recordNew
476
        ) throws PidUpdateException {
1✔
477
            for (Entry<Integer, HandleValue> old : recordOld.entrySet()) {
1✔
478
                boolean wasRemoved = !recordNew.containsKey(old.getKey());
1✔
479
                if (wasRemoved) {
1✔
480
                    // if a row in the record is not available anymore, we need to delete it
481
                    toRemove.add(old.getValue());
1✔
482
                } else {
483
                    // otherwise, we should go and update it.
484
                    // we could also check for equality, but this is the safe and easy way.
485
                    // (the handlevalue classes can be complicated and we'd have to check their
486
                    // equality implementation)
487
                    toUpdate.add(recordNew.get(old.getKey()));
1✔
488
                }
489
            }
1✔
490
            for (Entry<Integer, HandleValue> e : recordNew.entrySet()) {
1✔
491
                boolean isNew = !recordOld.containsKey(e.getKey());
1✔
492
                if (isNew) {
1✔
493
                    // if there is a record which is not in the oldRecord, we need to add it.
494
                    toAdd.add(e.getValue());
1✔
495
                }
496
            }
1✔
497

498
            // runtime testing to avoid messing up record states.
499
            String exceptionMsg = "DIFF NOT VALID. Type: %s. Value: %s";
1✔
500
            for (HandleValue v : toRemove) {
1✔
501
                boolean valid = recordOld.containsValue(v) && !recordNew.containsKey(v.getIndex());
1✔
502
                if (!valid) {
1✔
503
                    String message = String.format(exceptionMsg, "Remove", v.toString());
×
504
                    throw new PidUpdateException(message);
×
505
                }
506
            }
1✔
507
            for (HandleValue v : toAdd) {
1✔
508
                boolean valid = !recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v);
1✔
509
                if (!valid) {
1✔
510
                    String message = String.format(exceptionMsg, "Add", v);
×
511
                    throw new PidUpdateException(message);
×
512
                }
513
            }
1✔
514
            for (HandleValue v : toUpdate) {
1✔
515
                boolean valid = recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v);
1✔
516
                if (!valid) {
1✔
517
                    String message = String.format(exceptionMsg, "Update", v);
×
518
                    throw new PidUpdateException(message);
×
519
                }
520
            }
1✔
521
        }
1✔
522

523
        public HandleValue[] added() {
524
            return this.toAdd.toArray(new HandleValue[] {});
1✔
525
        }
526

527
        public HandleValue[] updated() {
528
            return this.toUpdate.toArray(new HandleValue[] {});
1✔
529
        }
530

531
        public HandleValue[] removed() {
532
            return this.toRemove.toArray(new HandleValue[] {});
1✔
533
        }
534
    }
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