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

gophercloud / gophercloud / 20260401226

16 Dec 2025 07:47AM UTC coverage: 63.775% (+0.01%) from 63.764%
20260401226

push

github

web-flow
Merge pull request #3576 from eshulman2/access_rule

Tokens: Add access rules support for application credentials

37 of 43 new or added lines in 4 files covered. (86.05%)

2 existing lines in 1 file now uncovered.

23857 of 37408 relevant lines covered (63.78%)

46.99 hits per line

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

34.89
/openstack/client.go
1
package openstack
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "reflect"
8
        "strings"
9

10
        "github.com/gophercloud/gophercloud/v2"
11
        tokens2 "github.com/gophercloud/gophercloud/v2/openstack/identity/v2/tokens"
12
        "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/ec2tokens"
13
        "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/oauth1"
14
        tokens3 "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
15
        "github.com/gophercloud/gophercloud/v2/openstack/utils"
16
)
17

18
const (
19
        // v2 represents Keystone v2.
20
        // It should never increase beyond 2.0.
21
        v2 = "v2.0"
22

23
        // v3 represents Keystone v3.
24
        // The version can be anything from v3 to v3.x.
25
        v3 = "v3"
26
)
27

28
// NewClient prepares an unauthenticated ProviderClient instance.
29
// Most users will probably prefer using the AuthenticatedClient function
30
// instead.
31
//
32
// This is useful if you wish to explicitly control the version of the identity
33
// service that's used for authentication explicitly, for example.
34
//
35
// A basic example of using this would be:
36
//
37
//        ao, err := openstack.AuthOptionsFromEnv()
38
//        provider, err := openstack.NewClient(ao.IdentityEndpoint)
39
//        client, err := openstack.NewIdentityV3(ctx, provider, gophercloud.EndpointOpts{})
40
func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
6✔
41
        base, err := utils.BaseEndpoint(endpoint)
6✔
42
        if err != nil {
6✔
43
                return nil, err
×
44
        }
×
45

46
        endpoint = gophercloud.NormalizeURL(endpoint)
6✔
47
        base = gophercloud.NormalizeURL(base)
6✔
48

6✔
49
        p := new(gophercloud.ProviderClient)
6✔
50
        p.IdentityBase = base
6✔
51
        p.IdentityEndpoint = endpoint
6✔
52
        p.UseTokenLock()
6✔
53

6✔
54
        return p, nil
6✔
55
}
56

57
// AuthenticatedClient logs in to an OpenStack cloud found at the identity endpoint
58
// specified by the options, acquires a token, and returns a Provider Client
59
// instance that's ready to operate.
60
//
61
// If the full path to a versioned identity endpoint was specified  (example:
62
// http://example.com:5000/v3), that path will be used as the endpoint to query.
63
//
64
// If a versionless endpoint was specified (example: http://example.com:5000/),
65
// the endpoint will be queried to determine which versions of the identity service
66
// are available, then chooses the most recent or most supported version.
67
//
68
// Example:
69
//
70
//        ao, err := openstack.AuthOptionsFromEnv()
71
//        provider, err := openstack.AuthenticatedClient(ctx, ao)
72
//        client, err := openstack.NewNetworkV2(ctx, provider, gophercloud.EndpointOpts{
73
//                Region: os.Getenv("OS_REGION_NAME"),
74
//        })
75
func AuthenticatedClient(ctx context.Context, options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
5✔
76
        client, err := NewClient(options.IdentityEndpoint)
5✔
77
        if err != nil {
5✔
78
                return nil, err
×
79
        }
×
80

81
        err = Authenticate(ctx, client, options)
5✔
82
        if err != nil {
7✔
83
                return nil, err
2✔
84
        }
2✔
85
        return client, nil
3✔
86
}
87

88
// Authenticate authenticates or re-authenticates against the most
89
// recent identity service supported at the provided endpoint.
90
func Authenticate(ctx context.Context, client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
5✔
91
        versions := []*utils.Version{
5✔
92
                {ID: v2, Priority: 20, Suffix: "/v2.0/"},
5✔
93
                {ID: v3, Priority: 30, Suffix: "/v3/"},
5✔
94
        }
5✔
95

5✔
96
        chosen, endpoint, err := utils.ChooseVersion(ctx, client, versions)
5✔
97
        if err != nil {
5✔
98
                return err
×
99
        }
×
100

101
        switch chosen.ID {
5✔
102
        case v2:
2✔
103
                return v2auth(ctx, client, endpoint, &options, gophercloud.EndpointOpts{})
2✔
104
        case v3:
3✔
105
                return v3auth(ctx, client, endpoint, &options, gophercloud.EndpointOpts{})
3✔
106
        default:
×
107
                // The switch statement must be out of date from the versions list.
×
108
                return fmt.Errorf("unrecognized identity version: %s", chosen.ID)
×
109
        }
110
}
111

112
// AuthenticateV2 explicitly authenticates against the identity v2 endpoint.
113
func AuthenticateV2(ctx context.Context, client *gophercloud.ProviderClient, options tokens2.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
×
114
        return v2auth(ctx, client, "", options, eo)
×
115
}
×
116

117
type v2TokenNoReauth struct {
118
        tokens2.AuthOptionsBuilder
119
}
120

121
func (v2TokenNoReauth) CanReauth() bool { return false }
×
122

123
func v2auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint string, options tokens2.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
2✔
124
        v2Client, err := NewIdentityV2(ctx, client, eo)
2✔
125
        if err != nil {
2✔
126
                return err
×
127
        }
×
128

129
        if endpoint != "" {
4✔
130
                v2Client.Endpoint = endpoint
2✔
131
        }
2✔
132

133
        result := tokens2.Create(ctx, v2Client, options)
2✔
134

2✔
135
        err = client.SetTokenAndAuthResult(result)
2✔
136
        if err != nil {
3✔
137
                return err
1✔
138
        }
1✔
139

140
        catalog, err := result.ExtractServiceCatalog()
1✔
141
        if err != nil {
1✔
142
                return err
×
143
        }
×
144

145
        if options.CanReauth() {
1✔
146
                // here we're creating a throw-away client (tac). it's a copy of the user's provider client, but
×
147
                // with the token and reauth func zeroed out. combined with setting `AllowReauth` to `false`,
×
148
                // this should retry authentication only once
×
149
                tac := *client
×
150
                tac.SetThrowaway(true)
×
151
                tac.ReauthFunc = nil
×
152
                err := tac.SetTokenAndAuthResult(nil)
×
153
                if err != nil {
×
154
                        return err
×
155
                }
×
156
                client.ReauthFunc = func(ctx context.Context) error {
×
157
                        err := v2auth(ctx, &tac, endpoint, &v2TokenNoReauth{options}, eo)
×
158
                        if err != nil {
×
159
                                return err
×
160
                        }
×
161
                        client.CopyTokenFrom(&tac)
×
162
                        return nil
×
163
                }
164
        }
165
        client.EndpointLocator = func(ctx context.Context, opts gophercloud.EndpointOpts) (string, error) {
1✔
166
                return V2Endpoint(ctx, client, catalog, opts)
×
167
        }
×
168

169
        return nil
1✔
170
}
171

172
// AuthenticateV3 explicitly authenticates against the identity v3 service.
173
func AuthenticateV3(ctx context.Context, client *gophercloud.ProviderClient, options tokens3.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
×
174
        return v3auth(ctx, client, "", options, eo)
×
175
}
×
176

177
func v3auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint string, opts tokens3.AuthOptionsBuilder, eo gophercloud.EndpointOpts) error {
3✔
178
        // Override the generated service endpoint with the one returned by the version endpoint.
3✔
179
        v3Client, err := NewIdentityV3(ctx, client, eo)
3✔
180
        if err != nil {
3✔
181
                return err
×
182
        }
×
183

184
        if endpoint != "" {
6✔
185
                v3Client.Endpoint = endpoint
3✔
186
        }
3✔
187

188
        var catalog *tokens3.ServiceCatalog
3✔
189

3✔
190
        var tokenID string
3✔
191
        // passthroughToken allows to passthrough the token without a scope
3✔
192
        var passthroughToken bool
3✔
193
        switch v := opts.(type) {
3✔
194
        case *gophercloud.AuthOptions:
3✔
195
                tokenID = v.TokenID
3✔
196
                passthroughToken = (v.Scope == nil || *v.Scope == gophercloud.AuthScope{})
3✔
197
        case *tokens3.AuthOptions:
×
198
                tokenID = v.TokenID
×
199
                passthroughToken = (v.Scope == tokens3.Scope{})
×
200
        }
201

202
        if tokenID != "" && passthroughToken {
3✔
203
                // passing through the token ID without requesting a new scope
×
204
                if opts.CanReauth() {
×
205
                        return fmt.Errorf("cannot use AllowReauth, when the token ID is defined and auth scope is not set")
×
206
                }
×
207

208
                v3Client.SetToken(tokenID)
×
NEW
209
                result := tokens3.Get(ctx, v3Client, tokenID, nil)
×
210
                if result.Err != nil {
×
211
                        return result.Err
×
212
                }
×
213

214
                err = client.SetTokenAndAuthResult(result)
×
215
                if err != nil {
×
216
                        return err
×
217
                }
×
218

219
                catalog, err = result.ExtractServiceCatalog()
×
220
                if err != nil {
×
221
                        return err
×
222
                }
×
223
        } else {
3✔
224
                var result tokens3.CreateResult
3✔
225
                switch opts.(type) {
3✔
226
                case *ec2tokens.AuthOptions:
×
227
                        result = ec2tokens.Create(ctx, v3Client, opts)
×
228
                case *oauth1.AuthOptions:
×
229
                        result = oauth1.Create(ctx, v3Client, opts)
×
230
                default:
3✔
231
                        result = tokens3.Create(ctx, v3Client, opts)
3✔
232
                }
233

234
                err = client.SetTokenAndAuthResult(result)
3✔
235
                if err != nil {
4✔
236
                        return err
1✔
237
                }
1✔
238

239
                catalog, err = result.ExtractServiceCatalog()
2✔
240
                if err != nil {
2✔
241
                        return err
×
242
                }
×
243
        }
244

245
        if opts.CanReauth() {
2✔
246
                // here we're creating a throw-away client (tac). it's a copy of the user's provider client, but
×
247
                // with the token and reauth func zeroed out. combined with setting `AllowReauth` to `false`,
×
248
                // this should retry authentication only once
×
249
                tac := *client
×
250
                tac.SetThrowaway(true)
×
251
                tac.ReauthFunc = nil
×
252
                err = tac.SetTokenAndAuthResult(nil)
×
253
                if err != nil {
×
254
                        return err
×
255
                }
×
256
                var tao tokens3.AuthOptionsBuilder
×
257
                switch ot := opts.(type) {
×
258
                case *gophercloud.AuthOptions:
×
259
                        o := *ot
×
260
                        o.AllowReauth = false
×
261
                        tao = &o
×
262
                case *tokens3.AuthOptions:
×
263
                        o := *ot
×
264
                        o.AllowReauth = false
×
265
                        tao = &o
×
266
                case *ec2tokens.AuthOptions:
×
267
                        o := *ot
×
268
                        o.AllowReauth = false
×
269
                        tao = &o
×
270
                case *oauth1.AuthOptions:
×
271
                        o := *ot
×
272
                        o.AllowReauth = false
×
273
                        tao = &o
×
274
                default:
×
275
                        tao = opts
×
276
                }
277
                client.ReauthFunc = func(ctx context.Context) error {
×
278
                        err := v3auth(ctx, &tac, endpoint, tao, eo)
×
279
                        if err != nil {
×
280
                                return err
×
281
                        }
×
282
                        client.CopyTokenFrom(&tac)
×
283
                        return nil
×
284
                }
285
        }
286
        client.EndpointLocator = func(ctx context.Context, opts gophercloud.EndpointOpts) (string, error) {
3✔
287
                return V3Endpoint(ctx, client, catalog, opts)
1✔
288
        }
1✔
289

290
        return nil
2✔
291
}
292

293
// NewIdentityV2 creates a ServiceClient that may be used to interact with the
294
// v2 identity service.
295
func NewIdentityV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
2✔
296
        endpoint := client.IdentityBase + "v2.0/"
2✔
297
        clientType := "identity"
2✔
298
        var err error
2✔
299
        if !reflect.DeepEqual(eo, gophercloud.EndpointOpts{}) {
2✔
300
                eo.ApplyDefaults(clientType)
×
301
                endpoint, err = client.EndpointLocator(ctx, eo)
×
302
                if err != nil {
×
303
                        return nil, err
×
304
                }
×
305
        }
306

307
        return &gophercloud.ServiceClient{
2✔
308
                ProviderClient: client,
2✔
309
                Endpoint:       endpoint,
2✔
310
                Type:           clientType,
2✔
311
        }, nil
2✔
312
}
313

314
// NewIdentityV3 creates a ServiceClient that may be used to access the v3
315
// identity service.
316
func NewIdentityV3(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
4✔
317
        endpoint := client.IdentityBase + "v3/"
4✔
318
        clientType := "identity"
4✔
319
        var err error
4✔
320
        if !reflect.DeepEqual(eo, gophercloud.EndpointOpts{}) {
5✔
321
                eo.ApplyDefaults(clientType)
1✔
322
                endpoint, err = client.EndpointLocator(ctx, eo)
1✔
323
                if err != nil {
1✔
324
                        return nil, err
×
325
                }
×
326
        }
327

328
        // Ensure endpoint still has a suffix of v3.
329
        // This is because EndpointLocator might have found a versionless
330
        // endpoint or the published endpoint is still /v2.0. In both
331
        // cases, we need to fix the endpoint to point to /v3.
332
        base, err := utils.BaseEndpoint(endpoint)
4✔
333
        if err != nil {
4✔
334
                return nil, err
×
335
        }
×
336

337
        base = gophercloud.NormalizeURL(base)
4✔
338

4✔
339
        endpoint = base + "v3/"
4✔
340

4✔
341
        return &gophercloud.ServiceClient{
4✔
342
                ProviderClient: client,
4✔
343
                Endpoint:       endpoint,
4✔
344
                Type:           clientType,
4✔
345
        }, nil
4✔
346
}
347

348
// TODO(stephenfin): Allow passing aliases to all New${SERVICE}V${VERSION} methods in v3
349
func initClientOpts(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts, clientType string, version int) (*gophercloud.ServiceClient, error) {
×
350
        sc := new(gophercloud.ServiceClient)
×
351

×
352
        eo.ApplyDefaults(clientType)
×
353
        if eo.Version != 0 && eo.Version != version {
×
354
                return sc, errors.New("conflict between requested service major version and manually set version")
×
355
        }
×
356
        eo.Version = version
×
357

×
358
        url, err := client.EndpointLocator(ctx, eo)
×
359
        if err != nil {
×
360
                return sc, err
×
361
        }
×
362

363
        sc.ProviderClient = client
×
364
        sc.Endpoint = url
×
365
        sc.Type = clientType
×
366
        return sc, nil
×
367
}
368

369
// NewBareMetalV1 creates a ServiceClient that may be used with the v1
370
// bare metal package.
371
func NewBareMetalV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
372
        sc, err := initClientOpts(ctx, client, eo, "baremetal", 1)
×
373
        if !strings.HasSuffix(strings.TrimSuffix(sc.Endpoint, "/"), "v1") {
×
374
                sc.ResourceBase = sc.Endpoint + "v1/"
×
375
        }
×
376
        return sc, err
×
377
}
378

379
// NewBareMetalIntrospectionV1 creates a ServiceClient that may be used with the v1
380
// bare metal introspection package.
381
func NewBareMetalIntrospectionV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
382
        return initClientOpts(ctx, client, eo, "baremetal-introspection", 1)
×
383
}
×
384

385
// NewObjectStorageV1 creates a ServiceClient that may be used with the v1
386
// object storage package.
387
func NewObjectStorageV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
388
        return initClientOpts(ctx, client, eo, "object-store", 1)
×
389
}
×
390

391
// NewComputeV2 creates a ServiceClient that may be used with the v2 compute
392
// package.
393
func NewComputeV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
394
        return initClientOpts(ctx, client, eo, "compute", 2)
×
395
}
×
396

397
// NewNetworkV2 creates a ServiceClient that may be used with the v2 network
398
// package.
399
func NewNetworkV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
400
        sc, err := initClientOpts(ctx, client, eo, "network", 2)
×
401
        sc.ResourceBase = sc.Endpoint + "v2.0/"
×
402
        return sc, err
×
403
}
×
404

405
// TODO(stephenfin): Remove this in v3. We no longer support the V1 Block Storage service.
406
// NewBlockStorageV1 creates a ServiceClient that may be used to access the v1
407
// block storage service.
408
func NewBlockStorageV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
409
        return initClientOpts(ctx, client, eo, "volume", 1)
×
410
}
×
411

412
// NewBlockStorageV2 creates a ServiceClient that may be used to access the v2
413
// block storage service.
414
func NewBlockStorageV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
415
        return initClientOpts(ctx, client, eo, "block-storage", 2)
×
416
}
×
417

418
// NewBlockStorageV3 creates a ServiceClient that may be used to access the v3 block storage service.
419
func NewBlockStorageV3(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
420
        return initClientOpts(ctx, client, eo, "block-storage", 3)
×
421
}
×
422

423
// NewSharedFileSystemV2 creates a ServiceClient that may be used to access the v2 shared file system service.
424
func NewSharedFileSystemV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
425
        return initClientOpts(ctx, client, eo, "shared-file-system", 2)
×
426
}
×
427

428
// NewOrchestrationV1 creates a ServiceClient that may be used to access the v1
429
// orchestration service.
430
func NewOrchestrationV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
431
        return initClientOpts(ctx, client, eo, "orchestration", 1)
×
432
}
×
433

434
// NewDBV1 creates a ServiceClient that may be used to access the v1 DB service.
435
func NewDBV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
436
        return initClientOpts(ctx, client, eo, "database", 1)
×
437
}
×
438

439
// NewDNSV2 creates a ServiceClient that may be used to access the v2 DNS
440
// service.
441
func NewDNSV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
442
        sc, err := initClientOpts(ctx, client, eo, "dns", 2)
×
443
        sc.ResourceBase = sc.Endpoint + "v2/"
×
444
        return sc, err
×
445
}
×
446

447
// NewImageV2 creates a ServiceClient that may be used to access the v2 image
448
// service.
449
func NewImageV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
450
        sc, err := initClientOpts(ctx, client, eo, "image", 2)
×
451
        sc.ResourceBase = sc.Endpoint + "v2/"
×
452
        return sc, err
×
453
}
×
454

455
// NewLoadBalancerV2 creates a ServiceClient that may be used to access the v2
456
// load balancer service.
457
func NewLoadBalancerV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
458
        sc, err := initClientOpts(ctx, client, eo, "load-balancer", 2)
×
459

×
460
        // Fixes edge case having an OpenStack lb endpoint with trailing version number.
×
461
        endpoint := strings.ReplaceAll(sc.Endpoint, "v2.0/", "")
×
462

×
463
        sc.ResourceBase = endpoint + "v2.0/"
×
464
        return sc, err
×
465
}
×
466

467
// NewMessagingV2 creates a ServiceClient that may be used with the v2 messaging
468
// service.
469
func NewMessagingV2(ctx context.Context, client *gophercloud.ProviderClient, clientID string, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
470
        sc, err := initClientOpts(ctx, client, eo, "message", 2)
×
471
        sc.MoreHeaders = map[string]string{"Client-ID": clientID}
×
472
        return sc, err
×
473
}
×
474

475
// NewContainerV1 creates a ServiceClient that may be used with v1 container package
476
func NewContainerV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
477
        return initClientOpts(ctx, client, eo, "application-container", 1)
×
478
}
×
479

480
// NewKeyManagerV1 creates a ServiceClient that may be used with the v1 key
481
// manager service.
482
func NewKeyManagerV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
483
        sc, err := initClientOpts(ctx, client, eo, "key-manager", 1)
×
484
        sc.ResourceBase = sc.Endpoint + "v1/"
×
485
        return sc, err
×
486
}
×
487

488
// NewContainerInfraV1 creates a ServiceClient that may be used with the v1 container infra management
489
// package.
490
func NewContainerInfraV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
491
        return initClientOpts(ctx, client, eo, "container-infrastructure-management", 1)
×
492
}
×
493

494
// NewWorkflowV2 creates a ServiceClient that may be used with the v2 workflow management package.
495
func NewWorkflowV2(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
496
        return initClientOpts(ctx, client, eo, "workflow", 2)
×
497
}
×
498

499
// NewPlacementV1 creates a ServiceClient that may be used with the placement package.
500
func NewPlacementV1(ctx context.Context, client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
×
501
        return initClientOpts(ctx, client, eo, "placement", 1)
×
502
}
×
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