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

pywbem / pywbemtools / test-2757

11 Jan 2026 09:04AM UTC coverage: 29.475% (-58.6%) from 88.026%
test-2757

Pull #1501

github

web-flow
Merge 4229b955d into f9b5e1682
Pull Request #1501: Fixed start program timeouts and other issues in listener tests; Enabled tests again

59 of 116 new or added lines in 3 files covered. (50.86%)

3924 existing lines in 32 files now uncovered.

1953 of 6626 relevant lines covered (29.47%)

0.59 hits per line

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

22.5
/pywbemtools/pywbemcli/_cmd_server.py
1
# (C) Copyright 2017 IBM Corp.
2
# (C) Copyright 2017 Inova Development Inc.
3
# All Rights Reserved
4
#
5
# Licensed under the Apache License, Version 2.0 (the "License");
6
# you may not use this file except in compliance with the License.
7
# You may obtain a copy of the License at
8
#
9
#    http://www.apache.org/licenses/LICENSE-2.0
10
#
11
# Unless required by applicable law or agreed to in writing, software
12
# distributed under the License is distributed on an "AS IS" BASIS,
13
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
# See the License for the specific language governing permissions and
15
# limitations under the License.
16
"""
2✔
17
Click command definition for the server command group which includes
18
cmds for inspection and management of the objects defined by the pywbem
19
server class including namespaces, WBEMServer information, and profile
20
information.
21

22
NOTE: Commands are ordered in help display by their order in this file.
23
"""
24

25

26
import os
2✔
27
import sys
2✔
28
import click
2✔
29

30
from pywbem import Error, MOFCompiler, ModelError
2✔
31
from pywbem._mof_compiler import MOFWBEMConnection, MOFCompileError
2✔
32
from pywbem._nocasedict import NocaseDict
2✔
33
from nocaselist import NocaseList
2✔
34

35
from .pywbemcli import cli
2✔
36
from ._common import pywbem_error_exception, parse_version_value, \
2✔
37
    is_experimental_class
38
from ._common_options import namespace_option
2✔
39
from .._click_extensions import PywbemtoolsGroup, PywbemtoolsCommand, \
2✔
40
    CMD_OPTS_TXT, GENERAL_OPTS_TXT, SUBCMD_HELP_TXT
41
from .._options import add_options, help_option
2✔
42
from .._output_formatting import validate_output_format, format_table, \
2✔
43
    display_text, fold_strings
44

45
# NOTE: A number of the options use double-dash as the short form.  In those
46
# cases, a third definition of the options without the double-dash defines
47
# the corresponding option name, ex. 'include_qualifiers'. It should be
48
# defined with underscore and not dash
49

50
#
51
#   Common option definitions for server group
52
#
53

54
mof_include_option = [              # pylint: disable=invalid-name
2✔
55
    click.option('--include', '-I', metavar='INCLUDEDIR', multiple=True,
56
                 help='Path name of a MOF include directory. '
57
                 'May be specified multiple times.')]
58

59
mof_dry_run_option = [              # pylint: disable=invalid-name
2✔
60
    click.option('--dry-run', '-d', is_flag=True, default=False,
61
                 help='Enable dry-run mode: Don\'t actually modify the '
62
                 'server. Connection to the server is still required for '
63
                 'reading.')]
64

65

66
@cli.group('server', cls=PywbemtoolsGroup, options_metavar=GENERAL_OPTS_TXT,
2✔
67
           subcommand_metavar=SUBCMD_HELP_TXT)
68
@add_options(help_option)
2✔
69
def server_group():
2✔
70
    """
71
    Command group for WBEM servers.
72

73
    This command group defines commands to inspect and manage core components
74
    of a WBEM server including server attributes, namespaces, compiling MOF,
75
    the Interop namespace and schema information.
76

77
    In addition to the command-specific options shown in this help text, the
78
    general options (see 'pywbemcli --help') can also be specified before the
79
    'server' keyword.
80
    """
UNCOV
81
    pass  # pylint: disable=unnecessary-pass
×
82

83

84
@server_group.command('brand', cls=PywbemtoolsCommand,
2✔
85
                      options_metavar=CMD_OPTS_TXT)
86
@add_options(help_option)
2✔
87
@click.pass_obj
2✔
88
def server_brand(context):
2✔
89
    """
90
    Get the brand of the server.
91

92
    Brand information is defined by the server implementor and may or may
93
    not be available. Pywbem attempts to collect the brand information from
94
    multiple sources.
95
    """
96
    # pylint: disable=too-many-function-args
UNCOV
97
    context.execute_cmd(lambda: cmd_server_brand(context))
×
98

99

100
@server_group.command('info', cls=PywbemtoolsCommand,
2✔
101
                      options_metavar=CMD_OPTS_TXT)
102
@add_options(help_option)
2✔
103
@click.pass_obj
2✔
104
def server_info(context):
2✔
105
    """
106
    Get information about the server.
107

108
    The information includes CIM namespaces and server brand.
109
    """
UNCOV
110
    context.execute_cmd(lambda: cmd_server_info(context))
×
111

112

113
@server_group.command('add-mof', cls=PywbemtoolsCommand,
2✔
114
                      options_metavar=CMD_OPTS_TXT)
115
@click.argument('moffiles', metavar='MOFFILE', type=click.Path(),
2✔
116
                nargs=-1, required=True)
117
@add_options(namespace_option)
2✔
118
@add_options(mof_include_option)
2✔
119
@add_options(mof_dry_run_option)
2✔
120
@add_options(help_option)
2✔
121
@click.pass_obj
2✔
122
def server_add_mof(context, **options):
2✔
123
    """
124
    Compile MOF and add/update CIM objects in the server.
125

126
    The MOF files are specified with the MOFFILE argument, which may be
127
    specified multiple times. The minus sign ('-') specifies the standard
128
    input.
129

130
    Initially, the target namespace is the namespace specified with the
131
    --namespace option or if not specified the default namespace of the
132
    connection. If the MOF contains '#pragma namespace' directives, the target
133
    namespace will be changed accordingly.
134

135
    MOF include files (specified with the '#pragma include' directive) are
136
    searched first in the directory of the including MOF file, and then in
137
    the directories specified with the --include option.
138

139
    Any CIM objects (instances, classes and qualifiers) specified in the MOF
140
    files are created in the server, or modified if they already exist in the
141
    server.
142

143
    The global --verbose option will show the CIM objects that are created or
144
    modified.
145
    """
UNCOV
146
    context.execute_cmd(lambda: cmd_server_add_mof(context, options))
×
147

148

149
@server_group.command('remove-mof', cls=PywbemtoolsCommand,
2✔
150
                      options_metavar=CMD_OPTS_TXT)
151
@click.argument('moffiles', metavar='MOFFILE', type=click.Path(),
2✔
152
                nargs=-1, required=True)
153
@add_options(namespace_option)
2✔
154
@add_options(mof_include_option)
2✔
155
@add_options(mof_dry_run_option)
2✔
156
@add_options(help_option)
2✔
157
@click.pass_obj
2✔
158
def server_remove_mof(context, **options):
2✔
159
    """
160
    Compile MOF and remove CIM objects from the server.
161

162
    The MOF files are specified with the MOFFILE argument, which may be
163
    specified multiple times. The minus sign ('-') specifies the standard
164
    input.
165

166
    Initially, the target namespace is the namespace specified with the
167
    --namespace option or if not specified the default namespace of the
168
    connection. If the MOF contains '#pragma namespace' directives, the target
169
    namespace will be changed accordingly.
170

171
    MOF include files (specified with the '#pragma include' directive) are
172
    searched first in the directory of the including MOF file, and then in
173
    the directories specified with the --include option.
174

175
    Any CIM objects (instances, classes and qualifiers) specified in the MOF
176
    files are deleted from the server.
177

178
    The global --verbose option will show the CIM objects that are removed.
179
    """
UNCOV
180
    context.execute_cmd(lambda: cmd_server_remove_mof(context, options))
×
181

182

183
@server_group.command('schema', cls=PywbemtoolsCommand,
2✔
184
                      options_metavar=CMD_OPTS_TXT)
185
@add_options(namespace_option)
2✔
186
@click.option('-d', '--detail', is_flag=True, default=False,
2✔
187
              help='Display details about each schema in the namespace rather '
188
                   'than accumulated for the namespace.')
189
@add_options(help_option)
2✔
190
@click.pass_obj
2✔
191
def server_schema(context, **options):
2✔
192
    """
193
    Get information about the server schemas.
194

195
    Gets information about the schemas and CIM schemas that define the classes
196
    in each namespace. The information provided includes:
197
      * The released DMTF CIM schema version that was the source for the
198
        qualifier declarations and classes for the namespace.
199
      * Experimental vs. final elements in the schema
200
      * Schema name (defined by the prefix on each class before the first '_')
201
      * Class count
202

203
    """
UNCOV
204
    context.execute_cmd(lambda: cmd_server_schema(context, options))
×
205

206

207
###############################################################
208
#         Server cmds
209
###############################################################
210

211

212
def cmd_server_brand(context):
2✔
213
    """
214
    Display product and version info of the current WBEM server
215
    """
UNCOV
216
    wbem_server = context.pywbem_server.wbem_server
×
UNCOV
217
    output_format = validate_output_format(context.output_format, 'TEXT')
×
218

UNCOV
219
    try:
×
UNCOV
220
        brand = wbem_server.brand
×
UNCOV
221
        context.spinner_stop()
×
222

UNCOV
223
        display_text(brand, output_format)
×
224

UNCOV
225
    except Error as er:
×
UNCOV
226
        raise pywbem_error_exception(er)
×
227

228

229
def cmd_server_info(context):
2✔
230
    """
231
    Display general overview of info from current WBEM server
232
    """
UNCOV
233
    wbem_server = context.pywbem_server.wbem_server
×
UNCOV
234
    output_format = validate_output_format(context.output_format, 'TABLE')
×
235

UNCOV
236
    try:
×
237
        # Execute the namespaces to force contact with server before
238
        # turning off the spinner.
UNCOV
239
        namespaces = sorted(wbem_server.namespaces)
×
UNCOV
240
        context.spinner_stop()
×
241

UNCOV
242
        rows = []
×
UNCOV
243
        headers = ['Brand', 'Version', 'Interop Namespace', 'Namespaces']
×
UNCOV
244
        sep = '\n' if namespaces and len(namespaces) > 3 else ', '
×
UNCOV
245
        namespaces = sep.join(namespaces)
×
246

UNCOV
247
        interop_ns = wbem_server.interop_ns  # Determines the Interop namespace
×
UNCOV
248
        rows.append([wbem_server.brand, wbem_server.version,
×
249
                     interop_ns, namespaces])
UNCOV
250
        click.echo(format_table(rows, headers,
×
251
                                title='Server General Information',
252
                                table_format=output_format))
253

UNCOV
254
    except Error as er:
×
UNCOV
255
        raise pywbem_error_exception(er)
×
256

257

258
def cmd_server_add_mof(context, options):
2✔
259
    """
260
    Compile MOF and add/update CIM objects in the server.
261
    """
UNCOV
262
    conn = context.pywbem_server.conn
×
263

UNCOV
264
    try:
×
265

UNCOV
266
        context.spinner_stop()
×
267

268
        # Define the connection to be used by the MOF compiler.
269
        # MOFWBEMConnection writes resulting CIM objects to a local store
270
        # but reads from the connection.
UNCOV
271
        if options['dry_run']:
×
UNCOV
272
            comp_handle = MOFWBEMConnection(conn=conn)
×
273
        else:
UNCOV
274
            comp_handle = conn
×
275

UNCOV
276
        if options['dry_run']:
×
UNCOV
277
            print('Executing in dry-run mode')
×
278

UNCOV
279
        include_dirs = []
×
UNCOV
280
        for idir in options['include']:
×
UNCOV
281
            if not os.path.isabs(idir):
×
UNCOV
282
                idir = os.path.abspath(idir)
×
UNCOV
283
            include_dirs.append(idir)
×
UNCOV
284
        for moffile in options['moffiles']:
×
UNCOV
285
            if moffile != '-':
×
UNCOV
286
                mofdir = os.path.dirname(moffile)
×
UNCOV
287
                if not os.path.isabs(mofdir):
×
UNCOV
288
                    mofdir = os.path.abspath(mofdir)
×
UNCOV
289
                for idir in include_dirs:
×
UNCOV
290
                    if mofdir.startswith(idir):
×
UNCOV
291
                        break
×
292
                else:
UNCOV
293
                    include_dirs.append(mofdir)
×
294

UNCOV
295
        mofcomp = MOFCompiler(handle=comp_handle, search_paths=include_dirs,
×
296
                              verbose=context.verbose)
297

UNCOV
298
        for moffile in options['moffiles']:
×
UNCOV
299
            if moffile == '-':
×
UNCOV
300
                mofstr = sys.stdin.read()  # bytes in py2 / text in py3
×
UNCOV
301
                if context.verbose:
×
UNCOV
302
                    print('Compiling MOF from standard input')
×
303
                # The defaulting to the connection default namespace is handled
304
                # inside of the MOF compiler.
UNCOV
305
                mofcomp.compile_string(mofstr, options['namespace'])
×
306
            else:
UNCOV
307
                if not os.path.isabs(moffile):
×
UNCOV
308
                    moffile = os.path.abspath(moffile)
×
UNCOV
309
                if context.verbose:
×
UNCOV
310
                    print(f'Compiling MOF file {moffile}')
×
311
                # The defaulting to the connection default namespace is handled
312
                # inside of the MOF compiler.
UNCOV
313
                mofcomp.compile_file(moffile, options['namespace'])
×
314

315
    # If MOFCompileError, exception already logged by compile_string().
UNCOV
316
    except MOFCompileError:
×
UNCOV
317
        raise click.ClickException("Compile failed.")
×
318

319
    # Otherwise display the exception itself
320
    except Error as exc:
×
321
        raise pywbem_error_exception(exc)
×
322

323

324
def cmd_server_remove_mof(context, options):
2✔
325
    """
326
    Compile MOF and remove CIM objects from the server.
327
    """
UNCOV
328
    conn = context.pywbem_server.conn
×
329

UNCOV
330
    try:
×
331

UNCOV
332
        context.spinner_stop()
×
333

334
        # Define the connection to be used by the MOF compiler.
335
        # MOFWBEMConnection writes resulting CIM objects to a local store
336
        # but reads from the connection.
UNCOV
337
        comp_handle = MOFWBEMConnection(conn=conn)
×
338

UNCOV
339
        if options['dry_run']:
×
UNCOV
340
            print('Executing in dry-run mode')
×
341

UNCOV
342
        include_dirs = []
×
UNCOV
343
        for idir in options['include']:
×
UNCOV
344
            if not os.path.isabs(idir):
×
UNCOV
345
                idir = os.path.abspath(idir)
×
UNCOV
346
            include_dirs.append(idir)
×
UNCOV
347
        for moffile in options['moffiles']:
×
UNCOV
348
            if moffile != '-':
×
UNCOV
349
                mofdir = os.path.dirname(moffile)
×
UNCOV
350
                if not os.path.isabs(mofdir):
×
UNCOV
351
                    mofdir = os.path.abspath(mofdir)
×
UNCOV
352
                for idir in include_dirs:
×
UNCOV
353
                    if mofdir.startswith(idir):
×
UNCOV
354
                        break
×
355
                else:
UNCOV
356
                    include_dirs.append(mofdir)
×
357

358
        # verbose messages are displayed by rollback()
UNCOV
359
        mofcomp = MOFCompiler(handle=comp_handle, search_paths=include_dirs,
×
360
                              verbose=False)
361

UNCOV
362
        for moffile in options['moffiles']:
×
UNCOV
363
            if moffile == '-':
×
UNCOV
364
                mofstr = sys.stdin.read()  # bytes in py2 / text in py3
×
UNCOV
365
                if context.verbose:
×
UNCOV
366
                    print('Compiling MOF from standard input into cache')
×
367
                # The defaulting to the connection default namespace is handled
368
                # inside of the MOF compiler.
UNCOV
369
                mofcomp.compile_string(mofstr, options['namespace'])
×
370
            else:
UNCOV
371
                if not os.path.isabs(moffile):
×
UNCOV
372
                    moffile = os.path.abspath(moffile)
×
UNCOV
373
                if context.verbose:
×
UNCOV
374
                    print(f'Compiling MOF file {moffile} into cache')
×
375
                # The defaulting to the connection default namespace is handled
376
                # inside of the MOF compiler.
UNCOV
377
                mofcomp.compile_file(moffile, options['namespace'])
×
378

379
        # rollback the compiled objects to remove them from the target.
UNCOV
380
        if not options['dry_run']:
×
UNCOV
381
            if context.verbose:
×
UNCOV
382
                print('Deleting CIM objects found in MOF...')
×
UNCOV
383
            comp_handle.rollback(verbose=context.verbose)
×
384
        else:
UNCOV
385
            if context.verbose:
×
UNCOV
386
                print('No deletions will be shown in dry-run mode')
×
387
    # If MOFCompileError, exception already logged by compile_string().
UNCOV
388
    except MOFCompileError:
×
UNCOV
389
        raise click.ClickException("Compile failed.")
×
390
    except Error as exc:
×
391
        raise pywbem_error_exception(exc)
×
392

393

394
def cmd_server_schema(context, options):
2✔
395
    """
396
    The schema command provides information on the CIM model in each namespace
397
    including the CIM Schema's defined, the DMTF Release schema version, whether
398
    the namespace/schema includes classes with the experimental qualifier, and
399
    the count of classes for the namespace and for each schema..
400
    """
401
    # The schema names that can be considered DMTF schemas and are part of
402
    # the dmtf_cim_schema
UNCOV
403
    possible_dmtf_schemas = NocaseList(['CIM', 'PRS'])
×
404

UNCOV
405
    def experimental_display(value):
×
406
        """Return string Experimental or empty sting"""
UNCOV
407
        return 'Experimental' if value else ''
×
408

UNCOV
409
    def schema_display(schema):
×
410
        """Replace dummy name for no-schema with real text"""
UNCOV
411
        if schema == "~~~":
×
412
            return "(no-schema)"
×
UNCOV
413
        return schema
×
414

UNCOV
415
    def version_str(version_tuple):
×
416
        """Convert 3 integer tuple to string  (1.2.3) or empty strig"""
UNCOV
417
        if all(i == version_tuple[0] for i in version_tuple):
×
UNCOV
418
            return ""
×
UNCOV
419
        return ".".join([str(i) for i in version_tuple])
×
420

UNCOV
421
    conn = context.pywbem_server.conn
×
UNCOV
422
    wbem_server = context.pywbem_server.wbem_server
×
423

UNCOV
424
    output_format = validate_output_format(context.output_format, 'TABLE')
×
UNCOV
425
    namespace_opt = options['namespace']
×
426

427
    # Get namespaces. This bypasses the issue whene there is no interop
428
    # namespace
UNCOV
429
    try:
×
UNCOV
430
        namespaces = [namespace_opt] if namespace_opt else \
×
431
            wbem_server.namespaces
UNCOV
432
    except ModelError:
×
UNCOV
433
        namespaces = [wbem_server.conn.default_namespace]
×
434

UNCOV
435
    detail = options['detail']
×
436

UNCOV
437
    rows = []
×
UNCOV
438
    for ns in sorted(namespaces):
×
UNCOV
439
        klasses = conn.EnumerateClasses(namespace=ns, DeepInheritance=True,
×
440
                                        LocalOnly=True)
UNCOV
441
        classes_count = len(klasses)
×
442
        # namespace level variables for experimental status and max version
UNCOV
443
        ns_experimental = False
×
UNCOV
444
        ns_max_dmtf_version = [0, 0, 0]
×
445

446
        # Dictionaries for schemas, schema_max_version and experimental status
447
        # per schema found in the namespaces
UNCOV
448
        schemas = NocaseDict()  # Schema names are case independent
×
UNCOV
449
        schema_max_ver = NocaseDict()
×
UNCOV
450
        schema_experimental = NocaseDict()
×
UNCOV
451
        no_schema = []
×
452

UNCOV
453
        for klass in klasses:
×
UNCOV
454
            schema_elements = klass.classname.split('_', 1)
×
UNCOV
455
            schema = schema_elements[0] if len(schema_elements) > 1 \
×
456
                else "~~~"  # this is dummy for sort that is replaced later.
457

UNCOV
458
            schemas[schema] = schemas.get(schema, 0) + 1
×
UNCOV
459
            if len(schema_elements) < 2:
×
460
                no_schema.append(klass.classname)
×
UNCOV
461
            if schema not in schema_max_ver:
×
UNCOV
462
                schema_max_ver[schema] = [0, 0, 0]
×
463

UNCOV
464
            this_class_experimental = False
×
465
            # Determine if experimental qualifier exists and set namespace
466
            # level experimental flag.
UNCOV
467
            if ns_experimental is False:
×
UNCOV
468
                if is_experimental_class(klass):
×
UNCOV
469
                    ns_experimental = True
×
UNCOV
470
                    this_class_experimental = True
×
471
            # If detail, set the schema level experimental flag
UNCOV
472
            if detail:
×
UNCOV
473
                if schema not in schema_experimental:
×
UNCOV
474
                    schema_experimental[schema] = False
×
475

UNCOV
476
                if this_class_experimental:
×
UNCOV
477
                    schema_experimental[schema] = True
×
UNCOV
478
                elif ns_experimental:
×
UNCOV
479
                    if schema_experimental[schema] is False:
×
UNCOV
480
                        if is_experimental_class(klass):
×
UNCOV
481
                            schema_experimental[schema] = True
×
482

483
            # Get the version qualifier for this class
UNCOV
484
            if 'Version' in klass.qualifiers:
×
UNCOV
485
                version = klass.qualifiers['Version'].value
×
UNCOV
486
                version = parse_version_value(version, klass.classname)
×
487

488
                # update the namespace max version if this schema is a
489
                # DMTF schema and not previously found
UNCOV
490
                if schema in possible_dmtf_schemas:
×
UNCOV
491
                    ns_max_dmtf_version = max(ns_max_dmtf_version, version)
×
492

493
                # update the version in the schema_max_ver dictionary
UNCOV
494
                if schema not in schema_max_ver or \
×
495
                        version > schema_max_ver[schema]:
UNCOV
496
                    schema_max_ver[schema] = version
×
497

498
        # Build the table formatted output
UNCOV
499
        prev_namespace = None
×
UNCOV
500
        ns_version_str = version_str(ns_max_dmtf_version) \
×
501
            if classes_count else ""
502

UNCOV
503
        if detail:
×
UNCOV
504
            headers = ['Namespace', 'schemas', 'classes\ncount',
×
505
                       'schema\nversion', 'experimental']
506
            # Display with a line for each namespace and one for each
507
            # schema in the namespace
508
            # replace the dummy "~~~" with the output text
UNCOV
509
            for schema in sorted(schemas.keys()):
×
UNCOV
510
                schema_max_ver_str = version_str(schema_max_ver[schema])
×
511
                # Set the namespace in first row for each new namespace found
UNCOV
512
                if ns != prev_namespace:
×
UNCOV
513
                    prev_namespace = ns
×
UNCOV
514
                    ns_display = ns
×
515
                else:
UNCOV
516
                    ns_display = ""
×
517
                # Append the row for each schema in the namespace
UNCOV
518
                rows.append([ns_display,              # namespace. don't repeat
×
519
                             schema_display(schema),  # CIM schema
520
                             schemas[schema],         #
521
                             schema_max_ver_str,      # schema version
522
                             experimental_display(schema_experimental[schema])])
523
        else:  # display non-detail report
524
            # Display one line for each namespace with list of schemas in the
525
            # namespace
UNCOV
526
            headers = ['Namespace', 'schemas', 'classes\ncount',
×
527
                       'CIM schema\nversion', 'experimental']
UNCOV
528
            schemas_str = ", ".join(sorted(list(schemas.keys())))
×
UNCOV
529
            schemas_str = schemas_str.replace('~~~', '(no-schema)')
×
UNCOV
530
            folded_schemas = fold_strings(schemas_str, 45,
×
531
                                          fold_list_items=False)
532

UNCOV
533
            rows.append([ns,
×
534
                         folded_schemas,
535
                         classes_count,
536
                         ns_version_str,
537
                         experimental_display(ns_experimental)
538
                         ])
539

540
    # if output_format_is_table(context.output_format):
UNCOV
541
    title = "Schema information{} namespaces: {};".format(
×
542
        '; detail;' if detail else ";", namespace_opt or "all")
543

UNCOV
544
    context.spinner_stop()
×
UNCOV
545
    click.echo(format_table(rows,
×
546
                            headers,
547
                            title=title,
548
                            table_format=output_format))
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