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

samirtahir91 / github-app-operator / 14269127110

04 Apr 2025 03:46PM UTC coverage: 70.681% (-0.9%) from 71.57%
14269127110

push

github

web-flow
chore(main): release 1.12.1 (#78)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

716 of 1013 relevant lines covered (70.68%)

0.75 hits per line

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

66.53
/internal/controller/githubapp_controller.go
1
/*
2
Copyright 2024.
3

4
Licensed under the Apache License, Version 2.0 (the "License");
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    http://www.apache.org/licenses/LICENSE-2.0
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package controller
18

19
import (
20
        "context"
21
        "encoding/json"
22
        "fmt"
23
        "math/rand"
24
        "net/http"
25
        "os"
26
        "path/filepath"
27
        "strconv"
28
        "sync"
29
        "time"
30

31
        "github.com/golang-jwt/jwt/v4"
32

33
        githubappv1 "github-app-operator/api/v1"
34

35
        vault "github.com/hashicorp/vault/api" // vault client
36
        appsv1 "k8s.io/api/apps/v1"
37
        corev1 "k8s.io/api/core/v1"
38
        apierrors "k8s.io/apimachinery/pkg/api/errors"
39
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
40
        "k8s.io/apimachinery/pkg/labels"
41
        "k8s.io/apimachinery/pkg/runtime"
42
        kubernetes "k8s.io/client-go/kubernetes" // k8s client
43
        "k8s.io/client-go/tools/record"
44
        ctrl "sigs.k8s.io/controller-runtime"
45
        "sigs.k8s.io/controller-runtime/pkg/builder" // Required for Watching
46
        "sigs.k8s.io/controller-runtime/pkg/client"
47
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
48
        "sigs.k8s.io/controller-runtime/pkg/event" // Required for Watching
49
        "sigs.k8s.io/controller-runtime/pkg/log"
50
        "sigs.k8s.io/controller-runtime/pkg/predicate" // Required for Watching
51
)
52

53
// Struct for GithubAppReconciler
54
type GithubAppReconciler struct {
55
        client.Client
56
        Scheme      *runtime.Scheme
57
        Recorder    record.EventRecorder
58
        HTTPClient  *http.Client
59
        VaultClient *vault.Client
60
        K8sClient   *kubernetes.Clientset
61
        lock        sync.Mutex
62
}
63

64
// Struct for GitHub App access token response
65
type Response struct {
66
        Token     string      `json:"token"`
67
        ExpiresAt metav1.Time `json:"expires_at"`
68
}
69

70
// Struct for GitHub App rate limit
71
type RateLimitInfo struct {
72
        Resources struct {
73
                Core struct {
74
                        Remaining int `json:"remaining"`
75
                } `json:"core"`
76
        } `json:"resources"`
77
}
78

79
// Struct to hold the GitHub API error response
80
type GithubErrorResponse struct {
81
        Message string `json:"message"`
82
}
83

84
var (
85
        defaultRequeueAfter     = 5 * time.Minute                  // Default requeue interval
86
        defaultTimeBeforeExpiry = 15 * time.Minute                 // Default time before expiry
87
        reconcileInterval       time.Duration                      // Requeue interval (from env var)
88
        timeBeforeExpiry        time.Duration                      // Expiry threshold (from env var)
89
        vaultAudience           = os.Getenv("VAULT_ROLE_AUDIENCE") // Vault audience bound to role
90
        vaultRole               = os.Getenv("VAULT_ROLE")          // Vault role to use
91
        serviceAccountName      string                             // Controller service account
92
        kubernetesNamespace     string                             // Controller namespace
93
        privateKeyCachePath     string                             // Path to store private keys
94
)
95

96
const (
97
        gitUsername = "not-used"
98
)
99

100
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps,verbs=get;list;watch;create;update;patch;delete
101
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps/status,verbs=get;update;patch
102
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps/finalizers,verbs=update
103
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;update;create;delete;watch;patch
104
// +kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;list;update;watch;patch
105
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
106
// +kubebuilder:rbac:groups=core,resources=serviceaccounts/token,verbs=create;get
107
// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=create;get
108

109
// Reconcile function
110
func (r *GithubAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
1✔
111
        // Acquire lock for the GitHubApp object
1✔
112
        r.lock.Lock()
1✔
113
        // Release lock
1✔
114
        defer r.lock.Unlock()
1✔
115

1✔
116
        l := log.FromContext(ctx)
1✔
117
        l.Info("Enter Reconcile")
1✔
118

1✔
119
        // Fetch the GithubApp instance
1✔
120
        githubApp := &githubappv1.GithubApp{}
1✔
121
        err := r.Get(ctx, req.NamespacedName, githubApp)
1✔
122
        if err != nil {
2✔
123
                if apierrors.IsNotFound(err) {
2✔
124
                        l.Info("GithubApp resource not found. Deleting managed objects and cache.")
1✔
125
                        // Delete owned access token secret
1✔
126
                        if err := r.deleteOwnedSecrets(ctx, githubApp); err != nil {
1✔
127
                                return ctrl.Result{}, err
×
128
                        }
×
129
                        // Delete private key cache
130
                        if err := deletePrivateKeyCache(req.Namespace, req.Name); err != nil {
1✔
131
                                return ctrl.Result{}, err
×
132
                        }
×
133
                        return ctrl.Result{}, nil
1✔
134
                }
135
                l.Error(err, "failed to get GithubApp")
×
136
                return ctrl.Result{}, err
×
137
        }
138

139
        /* Check if the GithubApp object is being deleted
140
        Remove access tokensecret if being deleted
141
        This should be handled by k8s garbage collection but just incase,
142
        we manually delete the secret.
143
        */
144
        if !githubApp.ObjectMeta.DeletionTimestamp.IsZero() {
1✔
145
                l.Info("GithubApp is being deleted. Deleting managed objects and cache.")
×
146
                // Delete owned access token secret
×
147
                if err := r.deleteOwnedSecrets(ctx, githubApp); err != nil {
×
148
                        return ctrl.Result{}, err
×
149
                }
×
150
                // Delete private key cache
151
                if err := deletePrivateKeyCache(req.Namespace, req.Name); err != nil {
×
152
                        return ctrl.Result{}, err
×
153
                }
×
154
                return ctrl.Result{}, nil
×
155
        }
156

157
        // Call the function to check if access token required
158
        // Will either create the access token secret or update it
159
        if err := r.checkExpiryAndUpdateAccessToken(ctx, githubApp); err != nil {
2✔
160
                l.Error(err, "failed to check expiry and update access token")
1✔
161
                // Update status field 'Error' with the error message
1✔
162
                if updateErr := r.updateStatusWithError(ctx, githubApp, err.Error()); updateErr != nil {
1✔
163
                        l.Error(updateErr, "failed to update status field 'Error'")
×
164
                }
×
165
                // Raise event
166
                r.Recorder.Event(
1✔
167
                        githubApp,
1✔
168
                        "Warning",
1✔
169
                        "FailedRenewal",
1✔
170
                        fmt.Sprintf("Error: %s", err),
1✔
171
                )
1✔
172
                return ctrl.Result{}, err
1✔
173
        }
174

175
        // Call the function to check expiry and renew the access token if required
176
        // Always requeue the githubApp for reconcile as per `reconcileInterval`
177
        requeueResult := checkExpiryAndRequeue(ctx, githubApp)
1✔
178

1✔
179
        // Clear the error field if no errors
1✔
180
        if githubApp.Status.Error != "" {
2✔
181
                githubApp.Status.Error = ""
1✔
182
                if err := r.Status().Update(ctx, githubApp); err != nil {
1✔
183
                        l.Error(err, "failed to clear status field 'Error' for GithubApp")
×
184
                        return ctrl.Result{}, err
×
185
                }
×
186
        }
187

188
        // Log and return
189
        l.Info("End Reconcile")
1✔
190
        fmt.Println()
1✔
191
        return requeueResult, nil
1✔
192
}
193

194
// Function to delete the access token secret owned by the GithubApp
195
func (r *GithubAppReconciler) deleteOwnedSecrets(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1✔
196
        secrets := &corev1.SecretList{}
1✔
197
        err := r.List(ctx, secrets, client.InNamespace(githubApp.Namespace))
1✔
198
        if err != nil {
1✔
199
                return err
×
200
        }
×
201

202
        for _, secret := range secrets.Items {
2✔
203
                for _, ownerRef := range secret.OwnerReferences {
2✔
204
                        if ownerRef.Kind == "GithubApp" && ownerRef.Name == githubApp.Name {
1✔
205
                                if err := r.Delete(ctx, &secret); err != nil {
×
206
                                        return err
×
207
                                }
×
208
                                break
×
209
                        }
210
                }
211
        }
212

213
        return nil
1✔
214
}
215

216
// Function to delete private key cache file for a GithubApp
217
func deletePrivateKeyCache(namespace string, name string) error {
1✔
218

1✔
219
        privateKeyPath := filepath.Join(privateKeyCachePath, namespace, name)
1✔
220
        // Remove cached private key
1✔
221
        err := os.Remove(privateKeyPath)
1✔
222
        if err != nil && !os.IsNotExist(err) {
1✔
223
                return fmt.Errorf("failed to remove cached private key: %v", err)
×
224
        }
×
225
        return nil
1✔
226
}
227

228
// Function to update the status field 'Error' of a GithubApp with an error message
229
func (r *GithubAppReconciler) updateStatusWithError(ctx context.Context, githubApp *githubappv1.GithubApp, errMsg string) error {
1✔
230
        // Update the error message in the status field
1✔
231
        githubApp.Status.Error = errMsg
1✔
232
        if err := r.Status().Update(ctx, githubApp); err != nil {
1✔
233
                return fmt.Errorf("failed to update status field 'Error' for GithubApp: %v", err)
×
234
        }
×
235

236
        return nil
1✔
237
}
238

239
// Function to check expiry and update access token
240
func (r *GithubAppReconciler) checkExpiryAndUpdateAccessToken(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1✔
241

1✔
242
        l := log.FromContext(ctx)
1✔
243

1✔
244
        // Get the expiresAt status field
1✔
245
        expiresAt := githubApp.Status.ExpiresAt.Time
1✔
246

1✔
247
        // If expiresAt status field is not present or expiry time has already passed, generate or renew access token
1✔
248
        if expiresAt.IsZero() || expiresAt.Before(time.Now()) {
2✔
249
                return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
250
        }
1✔
251

252
        // Check if the access token secret exists if not reconcile immediately
253
        accessTokenSecretKey := client.ObjectKey{
1✔
254
                Namespace: githubApp.Namespace,
1✔
255
                Name:      githubApp.Spec.AccessTokenSecret,
1✔
256
        }
1✔
257
        accessTokenSecret := &corev1.Secret{}
1✔
258
        if err := r.Get(ctx, accessTokenSecretKey, accessTokenSecret); err != nil {
2✔
259
                if apierrors.IsNotFound(err) {
2✔
260
                        // Secret doesn't exist, reconcile straight away
1✔
261
                        return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
262
                }
1✔
263
                // Error other than NotFound, return error
264
                return err
×
265
        }
266
        // Check if there are additional keys in the existing secret's data besides accessToken
267
        for key := range accessTokenSecret.Data {
2✔
268
                if key != "token" && key != "username" {
2✔
269
                        l.Info("Removing invalid key in access token secret", "Key", key)
1✔
270
                        return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
271
                }
1✔
272
        }
273

274
        // Check if the accessToken field exists and is not empty
275
        accessToken := string(accessTokenSecret.Data["token"])
1✔
276
        username := string(accessTokenSecret.Data["username"])
1✔
277

1✔
278
        // Check if the access token is a valid github token via gh api auth
1✔
279
        if !r.isAccessTokenValid(ctx, username, accessToken) {
2✔
280
                // If accessToken is invalid, generate or update access token
1✔
281
                return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
282
        }
1✔
283

284
        // Access token exists, calculate the duration until expiry
285
        durationUntilExpiry := time.Until(expiresAt)
1✔
286

1✔
287
        // If the expiry threshold met, generate or renew access token
1✔
288
        if durationUntilExpiry <= timeBeforeExpiry {
1✔
289
                l.Info(
×
290
                        "Expiry threshold reached - renewing",
×
291
                )
×
292
                return r.createOrUpdateAccessToken(ctx, githubApp)
×
293
        }
×
294

295
        return nil
1✔
296
}
297

298
// Function to check if the access token is valid by making a request to GitHub API
299
func (r *GithubAppReconciler) isAccessTokenValid(ctx context.Context, username string, accessToken string) bool {
1✔
300
        l := log.FromContext(ctx)
1✔
301

1✔
302
        // If username has been modified, renew the secret
1✔
303
        if username != gitUsername {
1✔
304
                l.Info(
×
305
                        "Username key is invalid, will renew",
×
306
                )
×
307
                return false
×
308
        }
×
309

310
        // GitHub API endpoint for rate limit information
311
        url := "https://api.github.com/rate_limit"
1✔
312

1✔
313
        // Create a new request
1✔
314
        ghReq, err := http.NewRequest("GET", url, nil)
1✔
315
        if err != nil {
1✔
316
                l.Error(err, "error creating request to GitHub API for rate limit")
×
317
                return false
×
318
        }
×
319

320
        // Add the access token to the request header
321
        ghReq.Header.Set("Authorization", "token "+accessToken)
1✔
322

1✔
323
        // Get the rate limit from GitHub API
1✔
324
        // Retry the request if any secondary rate limit error
1✔
325
        // Return an error if max retries reached
1✔
326
        maxRetries := 5
1✔
327
        for i := 0; i < maxRetries; i++ {
2✔
328
                // Send POST request for access token
1✔
329
                resp, err := r.HTTPClient.Do(ghReq)
1✔
330

1✔
331
                // if error break the loop
1✔
332
                if err != nil {
1✔
333
                        l.Error(err, "error sending request to GitHub API for rate limit")
×
334
                        return false
×
335
                }
×
336

337
                // Defer closing the response body and check for errors
338
                defer func() {
2✔
339
                        err := resp.Body.Close()
1✔
340
                        if err != nil {
1✔
341
                                l.Error(err, "error closing response body for api rate lmiit call")
×
342
                        }
×
343
                }()
344

345
                // Check if the response status code is 200 (OK)
346
                if resp.StatusCode == http.StatusOK {
2✔
347

1✔
348
                        // Decode the response body into the struct
1✔
349
                        var result RateLimitInfo
1✔
350
                        err = json.NewDecoder(resp.Body).Decode(&result)
1✔
351
                        if err != nil {
1✔
352
                                l.Error(err, "error decoding response body for rate limit")
×
353
                                return false
×
354
                        }
×
355

356
                        // Get rate limit
357
                        remaining := result.Resources.Core.Remaining
1✔
358

1✔
359
                        // Check if remaining rate limit is greater than 0
1✔
360
                        if remaining <= 0 {
1✔
361
                                l.Info("Rate limit exceeded for access token")
×
362
                                return false
×
363
                        }
×
364

365
                        // Rate limit is valid
366
                        l.Info("Rate limit is valid", "Remaining requests:", remaining)
1✔
367
                        return true
1✔
368
                }
369

370
                // If response failed due to 403 or 429 (GitHub rate limit errors)
371
                if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
1✔
372
                        l.Info("Retrying GitHub API rate limit call")
×
373
                        // Try use retry-after header
×
374
                        retryAfter, err := strconv.Atoi(resp.Header.Get("retry-after"))
×
375
                        if err != nil {
×
376
                                // default to 1s if header not present
×
377
                                retryAfter = 1
×
378
                        }
×
379
                        waitTime := time.Duration(retryAfter) * time.Second
×
380

×
381
                        // Add exponentional backoff
×
382
                        waitTime *= time.Duration(1 << i)
×
383

×
384
                        // Add jitter
×
385
                        waitTime += time.Duration(rand.Intn(500)) * time.Millisecond
×
386

×
387
                        time.Sleep(waitTime)
×
388
                } else {
1✔
389
                        // access token is invalid, renew it
1✔
390
                        l.Info(
1✔
391
                                "Access token is invalid, will renew",
1✔
392
                                "API Response code", resp.Status,
1✔
393
                        )
1✔
394
                        return false
1✔
395
                }
1✔
396
        }
397
        // max retries reached return error
398
        l.Error(nil, "error sending request to GitHub API for rate limit")
×
399
        return false
×
400
}
401

402
// Function to check expiry and requeue
403
func checkExpiryAndRequeue(ctx context.Context, githubApp *githubappv1.GithubApp) ctrl.Result {
1✔
404
        l := log.FromContext(ctx)
1✔
405

1✔
406
        // Get the expiresAt status field
1✔
407
        expiresAt := githubApp.Status.ExpiresAt.Time
1✔
408

1✔
409
        // Log the next expiry time
1✔
410
        l.Info("Next expiry time:", "expiresAt", expiresAt)
1✔
411

1✔
412
        // Return result with no error and request reconciliation after x minutes
1✔
413
        l.Info("Expiry threshold:", "Time", timeBeforeExpiry)
1✔
414
        l.Info("Requeue after:", "Time", reconcileInterval)
1✔
415
        return ctrl.Result{RequeueAfter: reconcileInterval}
1✔
416
}
1✔
417

418
// Function to get private key from a k8s secret
419
func (r *GithubAppReconciler) getPrivateKeyFromSecret(ctx context.Context, githubApp *githubappv1.GithubApp) ([]byte, error) {
1✔
420
        l := log.FromContext(ctx)
1✔
421

1✔
422
        // Get the private key from the Secret
1✔
423
        secretName := githubApp.Spec.PrivateKeySecret
1✔
424
        secretNamespace := githubApp.Namespace
1✔
425
        secret := &corev1.Secret{}
1✔
426
        err := r.Get(ctx, client.ObjectKey{Namespace: secretNamespace, Name: secretName}, secret)
1✔
427
        if err != nil {
2✔
428
                l.Error(err, "failed to get Secret")
1✔
429
                return []byte(""), err
1✔
430
        }
1✔
431

432
        privateKey, ok := secret.Data["privateKey"]
1✔
433
        if !ok {
2✔
434
                l.Error(err, "privateKey not found in Secret")
1✔
435
                return []byte(""), fmt.Errorf("privateKey not found in Secret")
1✔
436
        }
1✔
437
        return privateKey, nil
1✔
438
}
439

440
// Function to get private key from a Vault secret
441
func (r *GithubAppReconciler) getPrivateKeyFromVault(ctx context.Context, mountPath string, secretPath string, secretKey string) ([]byte, error) {
1✔
442

1✔
443
        // Get JWT from k8s Token Request API
1✔
444
        token, err := r.RequestToken(ctx, vaultAudience, kubernetesNamespace, serviceAccountName)
1✔
445
        if err != nil {
1✔
446
                return []byte(""), err
×
447
        }
×
448

449
        // Get private key from Vault secret with short-lived JWT
450
        privateKey, err := r.GetSecretWithKubernetesAuth(token, vaultRole, mountPath, secretPath, secretKey)
1✔
451
        if err != nil {
1✔
452
                return []byte(""), err
×
453
        }
×
454
        return privateKey, nil
1✔
455
}
456

457
// Function to get private key from a GCP secret
458
func (r *GithubAppReconciler) getPrivateKeyFromGcp(githubApp *githubappv1.GithubApp) ([]byte, error) {
×
459

×
460
        // Get the secret name for the GCP Secret
×
461
        secretName := githubApp.Spec.GcpPrivateKeySecret
×
462

×
463
        // Get private key from GCP Secret manager secret
×
464
        privateKey, err := r.GetSecretFromSecretMgr(secretName)
×
465
        if err != nil {
×
466
                return []byte(""), err
×
467
        }
×
468
        return privateKey, nil
×
469
}
470

471
// Function to get private key from local file cache
472
func getPrivateKeyFromCache(namespace string, name string) ([]byte, string, error) {
1✔
473

1✔
474
        // Try to get private key from local file system
1✔
475
        // Stores keys in <privateKeyCachePath>/<Namespace of githubapp>/<Name of githubapp>
1✔
476
        privateKeyDir := filepath.Join(privateKeyCachePath, namespace)
1✔
477
        privateKeyPath := filepath.Join(privateKeyDir, name)
1✔
478

1✔
479
        // Create dir if does not exist
1✔
480
        if _, err := os.Stat(privateKeyDir); os.IsNotExist(err) {
2✔
481
                if err := os.MkdirAll(privateKeyDir, 0700); err != nil {
1✔
482
                        return []byte(""), "", fmt.Errorf("failed to create private key directory: %v", err)
×
483
                }
×
484
        }
485
        if _, err := os.Stat(privateKeyPath); err == nil {
2✔
486
                // get private key if secret file exists
1✔
487
                privateKey, privateKeyErr := os.ReadFile(privateKeyPath)
1✔
488
                if privateKeyErr != nil {
1✔
489
                        return []byte(""), "", fmt.Errorf("failed to read private key from file: %v", privateKeyErr)
×
490
                }
×
491
                return privateKey, privateKeyPath, nil
1✔
492
        }
493
        // Return privateKeyPath if private key file doesn't exist
494
        return []byte(""), privateKeyPath, nil
1✔
495
}
496

497
// Function to get private key from cache, vault or k8s secret
498
func (r *GithubAppReconciler) getPrivateKey(ctx context.Context, githubApp *githubappv1.GithubApp) ([]byte, string, error) {
1✔
499

1✔
500
        var privateKey []byte
1✔
501
        var privateKeyPath string
1✔
502
        var privateKeyErr error
1✔
503

1✔
504
        // Try to get private key from local file system
1✔
505
        privateKey, privateKeyPath, privateKeyErr = getPrivateKeyFromCache(githubApp.Namespace, githubApp.Name)
1✔
506
        if privateKeyErr != nil {
1✔
507
                return []byte(""), "", privateKeyErr
×
508
        }
×
509

510
        // If private key file is not cached try to get it from Vault
511
        // Get the private key from a vault path if defined in Githubapp spec
512
        // Vault auth will take precedence over using `spec.privateKeySecret`
513
        if githubApp.Spec.VaultPrivateKey != nil && len(privateKey) == 0 {
2✔
514

1✔
515
                if r.VaultClient.Address() == "" || vaultAudience == "" || vaultRole == "" {
1✔
516
                        return []byte(""), "", fmt.Errorf("failed on vault auth: VAULT_ROLE, VAULT_ROLE_AUDIENCE and VAULT_ADDR are required env variables for Vault authentication")
×
517
                }
×
518

519
                mountPath := githubApp.Spec.VaultPrivateKey.MountPath
1✔
520
                secretPath := githubApp.Spec.VaultPrivateKey.SecretPath
1✔
521
                secretKey := githubApp.Spec.VaultPrivateKey.SecretKey
1✔
522
                privateKey, privateKeyErr = r.getPrivateKeyFromVault(ctx, mountPath, secretPath, secretKey)
1✔
523
                if privateKeyErr != nil {
1✔
524
                        return []byte(""), "", fmt.Errorf("failed to get private key from vault: %v", privateKeyErr)
×
525
                }
×
526
                if len(privateKey) == 0 {
1✔
527
                        return []byte(""), "", fmt.Errorf("empty private key from vault")
×
528
                }
×
529
                // Cache the private key to file
530
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
1✔
531
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
532
                }
×
533
        } else if githubApp.Spec.GcpPrivateKeySecret != "" && len(privateKey) == 0 {
1✔
534
                // else get the private key from GCP secret `spec.googlePrivateKeySecret`
×
535
                privateKey, privateKeyErr = r.getPrivateKeyFromGcp(githubApp)
×
536
                if privateKeyErr != nil {
×
537
                        return []byte(""), "", fmt.Errorf("failed to get private key from GCP secret: %v", privateKeyErr)
×
538
                }
×
539
                if len(privateKey) == 0 {
×
540
                        return []byte(""), "", fmt.Errorf("empty private key from GCP")
×
541
                }
×
542
                // Cache the private key to file
543
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
×
544
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
545
                }
×
546
        } else if githubApp.Spec.PrivateKeySecret != "" && len(privateKey) == 0 {
2✔
547
                // else get the private key from K8s secret `spec.privateKeySecret`
1✔
548
                privateKey, privateKeyErr = r.getPrivateKeyFromSecret(ctx, githubApp)
1✔
549
                if privateKeyErr != nil {
2✔
550
                        return []byte(""), "", fmt.Errorf("failed to get private key from kubernetes secret: %v", privateKeyErr)
1✔
551
                }
1✔
552
                if len(privateKey) == 0 {
1✔
553
                        return []byte(""), "", fmt.Errorf("empty private key from k8s secret")
×
554
                }
×
555
                // Cache the private key to file
556
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
1✔
557
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
558
                }
×
559
        }
560

561
        return privateKey, privateKeyPath, nil
1✔
562
}
563

564
// Function to create access token secret
565
func (r *GithubAppReconciler) createAccessTokenSecret(ctx context.Context, accessTokenSecret string, accessToken string, expiresAt metav1.Time, githubApp *githubappv1.GithubApp) error {
1✔
566
        l := log.FromContext(ctx)
1✔
567

1✔
568
        newSecret := &corev1.Secret{
1✔
569
                ObjectMeta: metav1.ObjectMeta{
1✔
570
                        Name:      accessTokenSecret,
1✔
571
                        Namespace: githubApp.Namespace,
1✔
572
                },
1✔
573
                StringData: map[string]string{
1✔
574
                        "token":    accessToken,
1✔
575
                        "username": gitUsername, // username is ignored in github auth but required
1✔
576
                },
1✔
577
        }
1✔
578

1✔
579
        // Set owner reference to GithubApp object
1✔
580
        if err := controllerutil.SetControllerReference(githubApp, newSecret, r.Scheme); err != nil {
1✔
581
                return fmt.Errorf("failed to set owner reference for access token secret: %v", err)
×
582
        }
×
583

584
        // Secret doesn't exist, create a new one
585
        if err := r.Create(ctx, newSecret); err != nil {
1✔
586
                return err
×
587
        }
×
588
        l.Info(
1✔
589
                "Secret created for access token",
1✔
590
                "Secret", accessTokenSecret,
1✔
591
        )
1✔
592
        // Raise event
1✔
593
        r.Recorder.Event(
1✔
594
                githubApp,
1✔
595
                "Normal",
1✔
596
                "Created",
1✔
597
                fmt.Sprintf("Created access token secret %s/%s", githubApp.Namespace, accessTokenSecret),
1✔
598
        )
1✔
599
        // Update the status with the new expiresAt time
1✔
600
        if err := updateGithubAppStatusWithRetry(ctx, r, githubApp, expiresAt, 3); err != nil {
1✔
601
                return fmt.Errorf("failed after creating secret: %v", err)
×
602
        }
×
603
        // Rollout deployments if required
604
        if err := r.rolloutDeployment(ctx, githubApp); err != nil {
1✔
605
                // Raise event
×
606
                r.Recorder.Event(
×
607
                        githubApp,
×
608
                        "Warning",
×
609
                        "FailedDeploymentUpgrade",
×
610
                        fmt.Sprintf("Error: %s", err),
×
611
                )
×
612
                return fmt.Errorf("failed to rollout deployment after after creating secret: %v", err)
×
613
        }
×
614
        return nil
1✔
615
}
616

617
// Function to update access token secret
618
func (r *GithubAppReconciler) updateAccessTokenSecret(ctx context.Context, existingSecret *corev1.Secret, accessTokenSecret string, accessToken string, expiresAt metav1.Time, githubApp *githubappv1.GithubApp) error {
1✔
619
        l := log.FromContext(ctx)
1✔
620
        // Set owner reference to GithubApp object
1✔
621
        if err := controllerutil.SetControllerReference(githubApp, existingSecret, r.Scheme); err != nil {
1✔
622
                return fmt.Errorf("failed to set owner reference for access token secret: %v", err)
×
623
        }
×
624
        // Clear existing data and set new access token data
625
        for k := range existingSecret.Data {
2✔
626
                delete(existingSecret.Data, k)
1✔
627
        }
1✔
628
        existingSecret.StringData = map[string]string{
1✔
629
                "token":    accessToken,
1✔
630
                "username": gitUsername,
1✔
631
        }
1✔
632
        if err := r.Update(ctx, existingSecret); err != nil {
1✔
633
                return err
×
634
        }
×
635

636
        // Update the status with the new expiresAt time
637
        if err := updateGithubAppStatusWithRetry(ctx, r, githubApp, expiresAt, 3); err != nil {
1✔
638
                return fmt.Errorf("failed after updating secret: %v", err)
×
639
        }
×
640
        // Restart the pods is required
641
        if err := r.rolloutDeployment(ctx, githubApp); err != nil {
1✔
642
                // Raise event
×
643
                r.Recorder.Event(
×
644
                        githubApp,
×
645
                        "Warning",
×
646
                        "FailedDeploymentUpgrade",
×
647
                        fmt.Sprintf("Error: %s", err),
×
648
                )
×
649
                return fmt.Errorf("failed to rollout deployment after updating secret: %v", err)
×
650
        }
×
651

652
        l.Info("Access token updated in the existing Secret successfully")
1✔
653
        // Raise event
1✔
654
        r.Recorder.Event(
1✔
655
                githubApp,
1✔
656
                "Normal",
1✔
657
                "Updated",
1✔
658
                fmt.Sprintf("Updated access token secret %s/%s", githubApp.Namespace, accessTokenSecret),
1✔
659
        )
1✔
660
        return nil
1✔
661
}
662

663
// Function to get a new access token and create or update a kubernetes secret with it
664
func (r *GithubAppReconciler) createOrUpdateAccessToken(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1✔
665
        l := log.FromContext(ctx)
1✔
666

1✔
667
        // Try to get private key from local file system
1✔
668
        privateKey, privateKeyPath, privateKeyErr := r.getPrivateKey(ctx, githubApp)
1✔
669
        if privateKeyErr != nil {
2✔
670
                return privateKeyErr
1✔
671
        }
1✔
672

673
        // Generate or renew access token
674
        accessToken, expiresAt, err := r.generateAccessToken(
1✔
675
                ctx,
1✔
676
                githubApp.Spec.AppId,
1✔
677
                githubApp.Spec.InstallId,
1✔
678
                privateKey,
1✔
679
        )
1✔
680
        // if GitHub API request for access token fails
1✔
681
        if err != nil {
1✔
682
                // Delete private key cache
×
683
                l.Error(nil, "Access token request failed, removing cached private key", "file", privateKeyPath)
×
684
                if err := deletePrivateKeyCache(githubApp.Namespace, githubApp.Name); err != nil {
×
685
                        l.Error(err, "failed to remove cached private key")
×
686
                }
×
687
                return fmt.Errorf("failed to generate access token: %v", err)
×
688
        }
689

690
        // Access token Kubernetes secret name
691
        accessTokenSecret := githubApp.Spec.AccessTokenSecret
1✔
692

1✔
693
        // Access token secret key
1✔
694
        accessTokenSecretKey := client.ObjectKey{
1✔
695
                Namespace: githubApp.Namespace,
1✔
696
                Name:      accessTokenSecret,
1✔
697
        }
1✔
698

1✔
699
        // Attempt to retrieve the existing Secret
1✔
700
        existingSecret := &corev1.Secret{}
1✔
701

1✔
702
        if err := r.Get(ctx, accessTokenSecretKey, existingSecret); err != nil {
2✔
703
                // Secret does not exist, create it
1✔
704
                if apierrors.IsNotFound(err) {
2✔
705
                        if err := r.createAccessTokenSecret(ctx, accessTokenSecret, accessToken, expiresAt, githubApp); err != nil {
1✔
706
                                l.Error(err, "failed to create Secret for access token")
×
707
                                return err
×
708
                        }
×
709
                        // secret created successfully, return here
710
                        return nil
1✔
711
                }
712
                // failed to create secret
713
                l.Error(
×
714
                        err,
×
715
                        "failed to get access token secret",
×
716
                        "Namespace", githubApp.Namespace,
×
717
                        "Secret", accessTokenSecret,
×
718
                )
×
719
                return fmt.Errorf("failed to get access token secret: %v", err)
×
720
        }
721

722
        // Secret exists, update it's data
723
        if err := r.updateAccessTokenSecret(ctx, existingSecret, accessTokenSecret, accessToken, expiresAt, githubApp); err != nil {
1✔
724
                l.Error(err, "failed to update Secret for access token")
×
725
                return err
×
726
        }
×
727

728
        return nil
1✔
729
}
730

731
// Function to update GithubApp status field with retry up to maxAttempts attempts
732
func updateGithubAppStatusWithRetry(ctx context.Context, r *GithubAppReconciler, githubApp *githubappv1.GithubApp, expiresAt metav1.Time, maxAttempts int) error {
1✔
733
        attempts := 0
1✔
734
        for {
2✔
735
                attempts++
1✔
736
                githubApp.Status.ExpiresAt = expiresAt
1✔
737
                err := r.Status().Update(ctx, githubApp)
1✔
738
                if err == nil {
2✔
739
                        return nil // Update successful
1✔
740
                }
1✔
741
                if apierrors.IsConflict(err) {
×
742
                        // Conflict error, retry the update
×
743
                        if attempts >= maxAttempts {
×
744
                                return fmt.Errorf("maximum retry attempts reached, failed to update GitHubApp status")
×
745
                        }
×
746
                        // Incremental sleep between attempts
747
                        time.Sleep(time.Duration(attempts*2) * time.Second)
×
748
                        continue
×
749
                }
750
                // Other error, return with the error
751
                return fmt.Errorf("failed to update GitHubApp status: %v", err)
×
752
        }
753
}
754

755
// Function to generate new access token for gh app
756
func (r *GithubAppReconciler) generateAccessToken(ctx context.Context, appID int, installationID int, privateKey []byte) (string, metav1.Time, error) {
1✔
757

1✔
758
        l := log.FromContext(ctx)
1✔
759

1✔
760
        // Parse private key
1✔
761
        parsedKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
1✔
762
        if err != nil {
1✔
763
                return "", metav1.Time{}, fmt.Errorf("failed to parse private key: %v", err)
×
764
        }
×
765

766
        // Generate JWT
767
        now := time.Now()
1✔
768
        claims := jwt.RegisteredClaims{
1✔
769
                Issuer:    fmt.Sprintf("%d", appID),
1✔
770
                IssuedAt:  jwt.NewNumericDate(now),
1✔
771
                ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // Expiry time is 10 minutes from now
1✔
772
        }
1✔
773
        token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
1✔
774
        signedToken, err := token.SignedString(parsedKey)
1✔
775
        if err != nil {
1✔
776
                return "", metav1.Time{}, fmt.Errorf("failed to sign JWT: %v", err)
×
777
        }
×
778

779
        // Use HTTP client and perform request to get installation token
780
        url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID)
1✔
781
        req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil)
1✔
782
        if err != nil {
1✔
783
                return "", metav1.Time{}, fmt.Errorf("failed to create HTTP request: %v", err)
×
784
        }
×
785
        req.Header.Set("Authorization", "Bearer "+signedToken)
1✔
786
        req.Header.Set("Accept", "application/vnd.github+json")
1✔
787

1✔
788
        // Get the access token from GitHub API
1✔
789
        // Retry the request if any rate limit error
1✔
790
        // Return an error if max retries reached
1✔
791
        maxRetries := 5
1✔
792
        for i := 0; i < maxRetries; i++ {
2✔
793
                // Send POST request for access token
1✔
794
                resp, err := r.HTTPClient.Do(req)
1✔
795

1✔
796
                // if error break the loop
1✔
797
                if err != nil {
1✔
798
                        return "", metav1.Time{}, fmt.Errorf("failed to send HTTP post request to GitHub API: %v", err)
×
799
                }
×
800

801
                // Defer closing the response body and check for errors
802
                defer func() {
2✔
803
                        err := resp.Body.Close()
1✔
804
                        if err != nil {
1✔
805
                                l.Error(err, "error closing response body for access token call")
×
806
                        }
×
807
                }()
808

809
                // If response is successful, parse token and expiry
810
                if resp.StatusCode == http.StatusCreated {
2✔
811
                        // Parse response
1✔
812
                        var responseBody Response
1✔
813
                        // if error in body break the loop, return error msg
1✔
814
                        if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
1✔
815
                                return "", metav1.Time{}, fmt.Errorf("failed to parse response body: %v", err)
×
816
                        }
×
817

818
                        // Got token and expiry
819
                        // return and break the loop
820
                        return responseBody.Token, responseBody.ExpiresAt, nil
1✔
821
                }
822

823
                // If response failed due to 403 or 429 (GitHub rate limit errors)
824
                if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
×
825
                        l.Info("Retrying GitHub API access token call")
×
826
                        // Try use retry-after header
×
827
                        retryAfter, err := strconv.Atoi(resp.Header.Get("retry-after"))
×
828
                        if err != nil {
×
829
                                // default to 1s if header not present
×
830
                                retryAfter = 1
×
831
                        }
×
832
                        waitTime := time.Duration(retryAfter) * time.Second
×
833

×
834
                        // Add exponentional backoff
×
835
                        waitTime *= time.Duration(1 << i)
×
836

×
837
                        // Add jitter
×
838
                        waitTime += time.Duration(rand.Intn(500)) * time.Millisecond
×
839

×
840
                        time.Sleep(waitTime)
×
841
                } else {
×
842
                        // If not a rate limit error/any other error
×
843
                        return "", metav1.Time{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
×
844
                }
×
845
        }
846
        // max retries reached return error
847
        return "", metav1.Time{}, fmt.Errorf("failed to get access token after %d retries", maxRetries)
×
848
}
849

850
// Function to upgrade deployments as per `spec.rolloutDeployment.labels` in GithubApp (in the same namespace)
851
func (r *GithubAppReconciler) rolloutDeployment(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1✔
852
        l := log.FromContext(ctx)
1✔
853

1✔
854
        // Check if rolloutDeployment field is defined
1✔
855
        if githubApp.Spec.RolloutDeployment == nil || len(githubApp.Spec.RolloutDeployment.Labels) == 0 {
2✔
856
                // No action needed if rolloutDeployment is not defined or no labels are specified
1✔
857
                return nil
1✔
858
        }
1✔
859

860
        // Loop through each label specified in rolloutDeployment.labels and update deployments matching each label
861
        for key, value := range githubApp.Spec.RolloutDeployment.Labels {
2✔
862
                // Create a list options with label selector
1✔
863
                listOptions := &client.ListOptions{
1✔
864
                        Namespace:     githubApp.Namespace,
1✔
865
                        LabelSelector: labels.SelectorFromSet(map[string]string{key: value}),
1✔
866
                }
1✔
867

1✔
868
                // List Deployments with the label selector
1✔
869
                deploymentList := &appsv1.DeploymentList{}
1✔
870
                if err := r.List(ctx, deploymentList, listOptions); err != nil {
1✔
871
                        return fmt.Errorf("failed to list Deployments with label %s=%s: %v", key, value, err)
×
872
                }
×
873

874
                // Trigger rolling upgrade for matching deployments
875
                for _, deployment := range deploymentList.Items {
2✔
876

1✔
877
                        // Add a timestamp label to trigger a rolling upgrade
1✔
878
                        deployment.Spec.Template.ObjectMeta.Labels["ghApplastUpdateTime"] = time.Now().Format("20060102150405")
1✔
879

1✔
880
                        // Patch the Deployment
1✔
881
                        if err := r.Update(ctx, &deployment); err != nil {
1✔
882
                                return fmt.Errorf(
×
883
                                        "failed to upgrade deployment %s/%s: %v",
×
884
                                        deployment.Namespace,
×
885
                                        deployment.Name,
×
886
                                        err,
×
887
                                )
×
888
                        }
×
889

890
                        // Log deployment upgrade
891
                        l.Info(
1✔
892
                                "Deployment rolling upgrade triggered",
1✔
893
                                "Name",
1✔
894
                                deployment.Name,
1✔
895
                                "Namespace",
1✔
896
                                deployment.Namespace,
1✔
897
                        )
1✔
898
                        // Raise event
1✔
899
                        r.Recorder.Event(
1✔
900
                                githubApp,
1✔
901
                                "Normal",
1✔
902
                                "Updated",
1✔
903
                                fmt.Sprintf("Updated deployment %s/%s", deployment.Namespace, deployment.Name),
1✔
904
                        )
1✔
905
                }
906
        }
907
        return nil
1✔
908
}
909

910
// Define a predicate function to filter create events for access token secrets
911
func accessTokenSecretPredicate() predicate.Predicate {
1✔
912
        return predicate.Funcs{
1✔
913
                CreateFunc: func(e event.CreateEvent) bool {
2✔
914
                        // Ignore create events for access token secrets
1✔
915
                        return false
1✔
916
                },
1✔
917
        }
918
}
919

920
/*
921
Define a predicate function to filter events for GithubApp objects
922
Check if the status field in ObjectOld is unset return false
923
Check if ExpiresAt is valid in the new GithubApp return false
924
Check if Error status field is cleared return false
925
Ignore status update event for GithubApp
926
*/
927
func githubAppPredicate() predicate.Predicate {
1✔
928
        return predicate.Funcs{
1✔
929
                UpdateFunc: func(e event.UpdateEvent) bool {
2✔
930
                        // Compare the old and new objects
1✔
931
                        oldGithubApp := e.ObjectOld.(*githubappv1.GithubApp)
1✔
932
                        newGithubApp := e.ObjectNew.(*githubappv1.GithubApp)
1✔
933

1✔
934
                        if oldGithubApp.Status.ExpiresAt.IsZero() &&
1✔
935
                                !newGithubApp.Status.ExpiresAt.IsZero() {
2✔
936
                                return false
1✔
937
                        }
1✔
938
                        if oldGithubApp.Status.Error != "" &&
1✔
939
                                newGithubApp.Status.Error == "" {
2✔
940
                                return false
1✔
941
                        }
1✔
942
                        return true
1✔
943
                },
944
        }
945
}
946

947
// Function to get service account and namespace of controller
948
func getServiceAccountAndNamespace(serviceAccountPath string) (string, string, error) {
1✔
949

1✔
950
        // Get KSA mounted in pod
1✔
951
        serviceAccountToken, err := os.ReadFile(serviceAccountPath)
1✔
952
        if err != nil {
1✔
953
                return "", "", fmt.Errorf("failed to read service account token: %v", err)
×
954
        }
×
955
        // Parse the KSA token
956
        parsedToken, _, err := new(jwt.Parser).ParseUnverified(string(serviceAccountToken), jwt.MapClaims{})
1✔
957
        if err != nil {
1✔
958
                return "", "", fmt.Errorf("failed to parse token: %v", err)
×
959
        }
×
960
        // Get the claims
961
        claims, ok := parsedToken.Claims.(jwt.MapClaims)
1✔
962
        if !ok {
1✔
963
                return "", "", fmt.Errorf("failed to parse token claims")
×
964
        }
×
965
        // Get kubernetes.io claims
966
        kubernetesClaims, ok := claims["kubernetes.io"].(map[string]interface{})
1✔
967
        if !ok {
1✔
968
                return "", "", fmt.Errorf("failed to assert kubernetes.io claim to map[string]interface{}")
×
969
        }
×
970
        // Get serviceaccount claim
971
        serviceAccountClaims, ok := kubernetesClaims["serviceaccount"].(map[string]interface{})
1✔
972
        if !ok {
1✔
973
                return "", "", fmt.Errorf("failed to assert serviceaccount claim to map[string]interface{}")
×
974
        }
×
975
        // Get the namespace
976
        kubernetesNamespace, ok := kubernetesClaims["namespace"].(string)
1✔
977
        if !ok {
1✔
978
                return "", "", fmt.Errorf("failed to assert namespace to string")
×
979
        }
×
980
        // Get service account name
981
        serviceAccountName, ok := serviceAccountClaims["name"].(string)
1✔
982
        if !ok {
1✔
983
                return "", "", fmt.Errorf("failed to assert service account name to string")
×
984
        }
×
985

986
        return serviceAccountName, kubernetesNamespace, nil
1✔
987
}
988

989
// SetupWithManager sets up the controller with the Manager.
990
func (r *GithubAppReconciler) SetupWithManager(mgr ctrl.Manager, privateKeyCache string, tokenPath ...string) error {
1✔
991

1✔
992
        // Set private key cache path
1✔
993
        privateKeyCachePath = privateKeyCache
1✔
994

1✔
995
        // Get reconcile interval from environment variable or use default value
1✔
996
        var err error
1✔
997
        reconcileIntervalStr := os.Getenv("CHECK_INTERVAL")
1✔
998
        reconcileInterval, err = time.ParseDuration(reconcileIntervalStr)
1✔
999
        if err != nil {
1✔
1000
                // Handle case where environment variable is not set or invalid
×
1001
                log.Log.Error(err, "failed to set reconcileInterval, defaulting")
×
1002
                reconcileInterval = defaultRequeueAfter
×
1003
        }
×
1004

1005
        // Get time before expiry from environment variable or use default value
1006
        timeBeforeExpiryStr := os.Getenv("EXPIRY_THRESHOLD")
1✔
1007
        timeBeforeExpiry, err = time.ParseDuration(timeBeforeExpiryStr)
1✔
1008
        if err != nil {
1✔
1009
                // Handle case where environment variable is not set or invalid
×
1010
                log.Log.Error(err, "failed to set timeBeforeExpiry, defaulting")
×
1011
                timeBeforeExpiry = defaultTimeBeforeExpiry
×
1012
        }
×
1013

1014
        // Get service account name and namespace
1015
        // Check if tokenPath is provided
1016
        var serviceAccountPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
1✔
1017
        if len(tokenPath) > 0 {
2✔
1018
                serviceAccountPath = tokenPath[0]
1✔
1019
        }
1✔
1020

1021
        serviceAccountName, kubernetesNamespace, err = getServiceAccountAndNamespace(serviceAccountPath)
1✔
1022
        if err != nil {
1✔
1023
                log.Log.Error(err, "failed to get service account and/or namespace of controller")
×
1024
        } else {
1✔
1025
                log.Log.Info("got controller service account and namespace", "service account", serviceAccountName, "namespace", kubernetesNamespace)
1✔
1026
        }
1✔
1027

1028
        return ctrl.NewControllerManagedBy(mgr).
1✔
1029
                // Watch GithubApps
1✔
1030
                For(&githubappv1.GithubApp{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, githubAppPredicate())).
1✔
1031
                // Watch access token secrets owned by GithubApps.
1✔
1032
                Owns(&corev1.Secret{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, accessTokenSecretPredicate())).
1✔
1033
                Complete(r)
1✔
1034
}
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