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

samirtahir91 / github-app-operator / 26223206224

21 May 2026 11:29AM UTC coverage: 71.896% (+1.2%) from 70.681%
26223206224

push

github

web-flow
feat: Add support for custom github hosts (#80)

33 of 37 new or added lines in 1 file covered. (89.19%)

747 of 1039 relevant lines covered (71.9%)

0.77 hits per line

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

68.37
/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
        "net/url"
26
        "os"
27
        "path/filepath"
28
        "strconv"
29
        "strings"
30
        "sync"
31
        "time"
32

33
        "github.com/golang-jwt/jwt/v4"
34

35
        githubappv1 "github-app-operator/api/v1"
36

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

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

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

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

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

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

98
const (
99
        gitUsername            = "not-used"
100
        defaultGitHubHost      = "github.com"
101
        defaultGitHubAPIHost   = "api.github.com"
102
        defaultGitHubAPIScheme = "https"
103
)
104

105
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps,verbs=get;list;watch;create;update;patch;delete
106
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps/status,verbs=get;update;patch
107
// +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps/finalizers,verbs=update
108
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;update;create;delete;watch;patch
109
// +kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;list;update;watch;patch
110
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
111
// +kubebuilder:rbac:groups=core,resources=serviceaccounts/token,verbs=create;get
112
// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=create;get
113

114
// Reconcile function
115
func (r *GithubAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
1✔
116
        // Acquire lock for the GitHubApp object
1✔
117
        r.lock.Lock()
1✔
118
        // Release lock
1✔
119
        defer r.lock.Unlock()
1✔
120

1✔
121
        l := log.FromContext(ctx)
1✔
122
        l.Info("Enter Reconcile")
1✔
123

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

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

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

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

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

193
        // Log and return
194
        l.Info("End Reconcile")
1✔
195
        fmt.Println()
1✔
196
        return requeueResult, nil
1✔
197
}
198

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

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

218
        return nil
1✔
219
}
220

221
// Function to delete private key cache file for a GithubApp
222
func deletePrivateKeyCache(namespace string, name string) error {
1✔
223

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

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

241
        return nil
1✔
242
}
243

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

1✔
247
        l := log.FromContext(ctx)
1✔
248
        githubHost, err := resolveGitHubHost(githubApp.Spec.GithubHost)
1✔
249
        if err != nil {
1✔
NEW
250
                return err
×
NEW
251
        }
×
252

253
        // Get the expiresAt status field
254
        expiresAt := githubApp.Status.ExpiresAt.Time
1✔
255

1✔
256
        // If expiresAt status field is not present or expiry time has already passed, generate or renew access token
1✔
257
        if expiresAt.IsZero() || expiresAt.Before(time.Now()) {
2✔
258
                return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
259
        }
1✔
260

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

283
        // Check if the accessToken field exists and is not empty
284
        accessToken := string(accessTokenSecret.Data["token"])
1✔
285
        username := string(accessTokenSecret.Data["username"])
1✔
286

1✔
287
        // Check if the access token is a valid github token via gh api auth
1✔
288
        if !r.isAccessTokenValid(ctx, username, accessToken, githubHost) {
2✔
289
                // If accessToken is invalid, generate or update access token
1✔
290
                return r.createOrUpdateAccessToken(ctx, githubApp)
1✔
291
        }
1✔
292

293
        // Access token exists, calculate the duration until expiry
294
        durationUntilExpiry := time.Until(expiresAt)
1✔
295

1✔
296
        // If the expiry threshold met, generate or renew access token
1✔
297
        if durationUntilExpiry <= timeBeforeExpiry {
1✔
298
                l.Info(
×
299
                        "Expiry threshold reached - renewing",
×
300
                )
×
301
                return r.createOrUpdateAccessToken(ctx, githubApp)
×
302
        }
×
303

304
        return nil
1✔
305
}
306

307
// Function to resolve GitHub host from spec and apply default when omitted.
308
func resolveGitHubHost(githubHost string) (string, error) {
1✔
309
        host := strings.TrimSpace(githubHost)
1✔
310
        if host == "" {
2✔
311
                return defaultGitHubHost, nil
1✔
312
        }
1✔
313

314
        // Accept hostnames with or without a URL scheme.
315
        if !strings.Contains(host, "://") {
2✔
316
                host = fmt.Sprintf("%s://%s", defaultGitHubAPIScheme, host)
1✔
317
        }
1✔
318

319
        parsedURL, err := url.Parse(host)
1✔
320
        if err != nil || parsedURL.Host == "" {
2✔
321
                return "", fmt.Errorf("invalid githubHost %q: must be a valid hostname", githubHost)
1✔
322
        }
1✔
323
        if parsedURL.Path != "" && parsedURL.Path != "/" {
2✔
324
                return "", fmt.Errorf("invalid githubHost %q: must not include a URL path", githubHost)
1✔
325
        }
1✔
326

327
        return parsedURL.Host, nil
1✔
328
}
329

330
// Function to return the REST API base URL from a GitHub host.
331
func githubAPIBaseURL(githubHost string) string {
1✔
332
        if githubHost == defaultGitHubHost || githubHost == defaultGitHubAPIHost {
2✔
333
                return fmt.Sprintf("%s://%s", defaultGitHubAPIScheme, defaultGitHubAPIHost)
1✔
334
        }
1✔
335
        return fmt.Sprintf("%s://%s/api/v3", defaultGitHubAPIScheme, githubHost)
1✔
336
}
337

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

1✔
342
        // If username has been modified, renew the secret
1✔
343
        if username != gitUsername {
1✔
344
                l.Info(
×
345
                        "Username key is invalid, will renew",
×
346
                )
×
347
                return false
×
348
        }
×
349

350
        // GitHub API endpoint for rate limit information
351
        endpointURL := fmt.Sprintf("%s/rate_limit", githubAPIBaseURL(githubHost))
1✔
352

1✔
353
        // Create a new request
1✔
354
        ghReq, err := http.NewRequest("GET", endpointURL, nil)
1✔
355
        if err != nil {
1✔
356
                l.Error(err, "error creating request to GitHub API for rate limit")
×
357
                return false
×
358
        }
×
359

360
        // Add the access token to the request header
361
        ghReq.Header.Set("Authorization", "token "+accessToken)
1✔
362

1✔
363
        // Get the rate limit from GitHub API
1✔
364
        // Retry the request if any secondary rate limit error
1✔
365
        // Return an error if max retries reached
1✔
366
        maxRetries := 5
1✔
367
        for i := 0; i < maxRetries; i++ {
2✔
368
                // Send POST request for access token
1✔
369
                resp, err := r.HTTPClient.Do(ghReq)
1✔
370

1✔
371
                // if error break the loop
1✔
372
                if err != nil {
1✔
373
                        l.Error(err, "error sending request to GitHub API for rate limit")
×
374
                        return false
×
375
                }
×
376

377
                // Defer closing the response body and check for errors
378
                defer func() {
2✔
379
                        err := resp.Body.Close()
1✔
380
                        if err != nil {
1✔
381
                                l.Error(err, "error closing response body for api rate lmiit call")
×
382
                        }
×
383
                }()
384

385
                // Check if the response status code is 200 (OK)
386
                if resp.StatusCode == http.StatusOK {
2✔
387

1✔
388
                        // Decode the response body into the struct
1✔
389
                        var result RateLimitInfo
1✔
390
                        err = json.NewDecoder(resp.Body).Decode(&result)
1✔
391
                        if err != nil {
1✔
392
                                l.Error(err, "error decoding response body for rate limit")
×
393
                                return false
×
394
                        }
×
395

396
                        // Get rate limit
397
                        remaining := result.Resources.Core.Remaining
1✔
398

1✔
399
                        // Check if remaining rate limit is greater than 0
1✔
400
                        if remaining <= 0 {
1✔
401
                                l.Info("Rate limit exceeded for access token")
×
402
                                return false
×
403
                        }
×
404

405
                        // Rate limit is valid
406
                        l.Info("Rate limit is valid", "Remaining requests:", remaining)
1✔
407
                        return true
1✔
408
                }
409

410
                // If response failed due to 403 or 429 (GitHub rate limit errors)
411
                if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
1✔
412
                        l.Info("Retrying GitHub API rate limit call")
×
413
                        // Try use retry-after header
×
414
                        retryAfter, err := strconv.Atoi(resp.Header.Get("retry-after"))
×
415
                        if err != nil {
×
416
                                // default to 1s if header not present
×
417
                                retryAfter = 1
×
418
                        }
×
419
                        waitTime := time.Duration(retryAfter) * time.Second
×
420

×
421
                        // Add exponentional backoff
×
422
                        waitTime *= time.Duration(1 << i)
×
423

×
424
                        // Add jitter
×
425
                        waitTime += time.Duration(rand.Intn(500)) * time.Millisecond
×
426

×
427
                        time.Sleep(waitTime)
×
428
                } else {
1✔
429
                        // access token is invalid, renew it
1✔
430
                        l.Info(
1✔
431
                                "Access token is invalid, will renew",
1✔
432
                                "API Response code", resp.Status,
1✔
433
                        )
1✔
434
                        return false
1✔
435
                }
1✔
436
        }
437
        // max retries reached return error
438
        l.Error(nil, "error sending request to GitHub API for rate limit")
×
439
        return false
×
440
}
441

442
// Function to check expiry and requeue
443
func checkExpiryAndRequeue(ctx context.Context, githubApp *githubappv1.GithubApp) ctrl.Result {
1✔
444
        l := log.FromContext(ctx)
1✔
445

1✔
446
        // Get the expiresAt status field
1✔
447
        expiresAt := githubApp.Status.ExpiresAt.Time
1✔
448

1✔
449
        // Log the next expiry time
1✔
450
        l.Info("Next expiry time:", "expiresAt", expiresAt)
1✔
451

1✔
452
        // Return result with no error and request reconciliation after x minutes
1✔
453
        l.Info("Expiry threshold:", "Time", timeBeforeExpiry)
1✔
454
        l.Info("Requeue after:", "Time", reconcileInterval)
1✔
455
        return ctrl.Result{RequeueAfter: reconcileInterval}
1✔
456
}
1✔
457

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

1✔
462
        // Get the private key from the Secret
1✔
463
        secretName := githubApp.Spec.PrivateKeySecret
1✔
464
        secretNamespace := githubApp.Namespace
1✔
465
        secret := &corev1.Secret{}
1✔
466
        err := r.Get(ctx, client.ObjectKey{Namespace: secretNamespace, Name: secretName}, secret)
1✔
467
        if err != nil {
2✔
468
                l.Error(err, "failed to get Secret")
1✔
469
                return []byte(""), err
1✔
470
        }
1✔
471

472
        privateKey, ok := secret.Data["privateKey"]
1✔
473
        if !ok {
2✔
474
                l.Error(err, "privateKey not found in Secret")
1✔
475
                return []byte(""), fmt.Errorf("privateKey not found in Secret")
1✔
476
        }
1✔
477
        return privateKey, nil
1✔
478
}
479

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

1✔
483
        // Get JWT from k8s Token Request API
1✔
484
        token, err := r.RequestToken(ctx, vaultAudience, kubernetesNamespace, serviceAccountName)
1✔
485
        if err != nil {
1✔
486
                return []byte(""), err
×
487
        }
×
488

489
        // Get private key from Vault secret with short-lived JWT
490
        privateKey, err := r.GetSecretWithKubernetesAuth(token, vaultRole, mountPath, secretPath, secretKey)
1✔
491
        if err != nil {
1✔
492
                return []byte(""), err
×
493
        }
×
494
        return privateKey, nil
1✔
495
}
496

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

×
500
        // Get the secret name for the GCP Secret
×
501
        secretName := githubApp.Spec.GcpPrivateKeySecret
×
502

×
503
        // Get private key from GCP Secret manager secret
×
504
        privateKey, err := r.GetSecretFromSecretMgr(secretName)
×
505
        if err != nil {
×
506
                return []byte(""), err
×
507
        }
×
508
        return privateKey, nil
×
509
}
510

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

1✔
514
        // Try to get private key from local file system
1✔
515
        // Stores keys in <privateKeyCachePath>/<Namespace of githubapp>/<Name of githubapp>
1✔
516
        privateKeyDir := filepath.Join(privateKeyCachePath, namespace)
1✔
517
        privateKeyPath := filepath.Join(privateKeyDir, name)
1✔
518

1✔
519
        // Create dir if does not exist
1✔
520
        if _, err := os.Stat(privateKeyDir); os.IsNotExist(err) {
2✔
521
                if err := os.MkdirAll(privateKeyDir, 0700); err != nil {
1✔
522
                        return []byte(""), "", fmt.Errorf("failed to create private key directory: %v", err)
×
523
                }
×
524
        }
525
        if _, err := os.Stat(privateKeyPath); err == nil {
2✔
526
                // get private key if secret file exists
1✔
527
                privateKey, privateKeyErr := os.ReadFile(privateKeyPath)
1✔
528
                if privateKeyErr != nil {
1✔
529
                        return []byte(""), "", fmt.Errorf("failed to read private key from file: %v", privateKeyErr)
×
530
                }
×
531
                return privateKey, privateKeyPath, nil
1✔
532
        }
533
        // Return privateKeyPath if private key file doesn't exist
534
        return []byte(""), privateKeyPath, nil
1✔
535
}
536

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

1✔
540
        var privateKey []byte
1✔
541
        var privateKeyPath string
1✔
542
        var privateKeyErr error
1✔
543

1✔
544
        // Try to get private key from local file system
1✔
545
        privateKey, privateKeyPath, privateKeyErr = getPrivateKeyFromCache(githubApp.Namespace, githubApp.Name)
1✔
546
        if privateKeyErr != nil {
1✔
547
                return []byte(""), "", privateKeyErr
×
548
        }
×
549

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

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

559
                mountPath := githubApp.Spec.VaultPrivateKey.MountPath
1✔
560
                secretPath := githubApp.Spec.VaultPrivateKey.SecretPath
1✔
561
                secretKey := githubApp.Spec.VaultPrivateKey.SecretKey
1✔
562
                privateKey, privateKeyErr = r.getPrivateKeyFromVault(ctx, mountPath, secretPath, secretKey)
1✔
563
                if privateKeyErr != nil {
1✔
564
                        return []byte(""), "", fmt.Errorf("failed to get private key from vault: %v", privateKeyErr)
×
565
                }
×
566
                if len(privateKey) == 0 {
1✔
567
                        return []byte(""), "", fmt.Errorf("empty private key from vault")
×
568
                }
×
569
                // Cache the private key to file
570
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
1✔
571
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
572
                }
×
573
        } else if githubApp.Spec.GcpPrivateKeySecret != "" && len(privateKey) == 0 {
1✔
574
                // else get the private key from GCP secret `spec.googlePrivateKeySecret`
×
575
                privateKey, privateKeyErr = r.getPrivateKeyFromGcp(githubApp)
×
576
                if privateKeyErr != nil {
×
577
                        return []byte(""), "", fmt.Errorf("failed to get private key from GCP secret: %v", privateKeyErr)
×
578
                }
×
579
                if len(privateKey) == 0 {
×
580
                        return []byte(""), "", fmt.Errorf("empty private key from GCP")
×
581
                }
×
582
                // Cache the private key to file
583
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
×
584
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
585
                }
×
586
        } else if githubApp.Spec.PrivateKeySecret != "" && len(privateKey) == 0 {
2✔
587
                // else get the private key from K8s secret `spec.privateKeySecret`
1✔
588
                privateKey, privateKeyErr = r.getPrivateKeyFromSecret(ctx, githubApp)
1✔
589
                if privateKeyErr != nil {
2✔
590
                        return []byte(""), "", fmt.Errorf("failed to get private key from kubernetes secret: %v", privateKeyErr)
1✔
591
                }
1✔
592
                if len(privateKey) == 0 {
1✔
593
                        return []byte(""), "", fmt.Errorf("empty private key from k8s secret")
×
594
                }
×
595
                // Cache the private key to file
596
                if err := os.WriteFile(privateKeyPath, privateKey, 0600); err != nil {
1✔
597
                        return []byte(""), "", fmt.Errorf("failed to write private key to file: %v", err)
×
598
                }
×
599
        }
600

601
        return privateKey, privateKeyPath, nil
1✔
602
}
603

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

1✔
608
        newSecret := &corev1.Secret{
1✔
609
                ObjectMeta: metav1.ObjectMeta{
1✔
610
                        Name:      accessTokenSecret,
1✔
611
                        Namespace: githubApp.Namespace,
1✔
612
                },
1✔
613
                StringData: map[string]string{
1✔
614
                        "token":    accessToken,
1✔
615
                        "username": gitUsername, // username is ignored in github auth but required
1✔
616
                },
1✔
617
        }
1✔
618

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

624
        // Secret doesn't exist, create a new one
625
        if err := r.Create(ctx, newSecret); err != nil {
2✔
626
                return err
1✔
627
        }
1✔
628
        l.Info(
1✔
629
                "Secret created for access token",
1✔
630
                "Secret", accessTokenSecret,
1✔
631
        )
1✔
632
        // Raise event
1✔
633
        r.Recorder.Event(
1✔
634
                githubApp,
1✔
635
                "Normal",
1✔
636
                "Created",
1✔
637
                fmt.Sprintf("Created access token secret %s/%s", githubApp.Namespace, accessTokenSecret),
1✔
638
        )
1✔
639
        // Update the status with the new expiresAt time
1✔
640
        if err := updateGithubAppStatusWithRetry(ctx, r, githubApp, expiresAt, 3); err != nil {
1✔
641
                return fmt.Errorf("failed after creating secret: %v", err)
×
642
        }
×
643
        // Rollout deployments if required
644
        if err := r.rolloutDeployment(ctx, githubApp); err != nil {
1✔
645
                // Raise event
×
646
                r.Recorder.Event(
×
647
                        githubApp,
×
648
                        "Warning",
×
649
                        "FailedDeploymentUpgrade",
×
650
                        fmt.Sprintf("Error: %s", err),
×
651
                )
×
652
                return fmt.Errorf("failed to rollout deployment after after creating secret: %v", err)
×
653
        }
×
654
        return nil
1✔
655
}
656

657
// Function to update access token secret
658
func (r *GithubAppReconciler) updateAccessTokenSecret(ctx context.Context, existingSecret *corev1.Secret, accessTokenSecret string, accessToken string, expiresAt metav1.Time, githubApp *githubappv1.GithubApp) error {
1✔
659
        l := log.FromContext(ctx)
1✔
660
        // Set owner reference to GithubApp object
1✔
661
        if err := controllerutil.SetControllerReference(githubApp, existingSecret, r.Scheme); err != nil {
1✔
662
                return fmt.Errorf("failed to set owner reference for access token secret: %v", err)
×
663
        }
×
664
        // Clear existing data and set new access token data
665
        for k := range existingSecret.Data {
2✔
666
                delete(existingSecret.Data, k)
1✔
667
        }
1✔
668
        existingSecret.StringData = map[string]string{
1✔
669
                "token":    accessToken,
1✔
670
                "username": gitUsername,
1✔
671
        }
1✔
672
        if err := r.Update(ctx, existingSecret); err != nil {
1✔
673
                return err
×
674
        }
×
675

676
        // Update the status with the new expiresAt time
677
        if err := updateGithubAppStatusWithRetry(ctx, r, githubApp, expiresAt, 3); err != nil {
1✔
678
                return fmt.Errorf("failed after updating secret: %v", err)
×
679
        }
×
680
        // Restart the pods is required
681
        if err := r.rolloutDeployment(ctx, githubApp); err != nil {
1✔
682
                // Raise event
×
683
                r.Recorder.Event(
×
684
                        githubApp,
×
685
                        "Warning",
×
686
                        "FailedDeploymentUpgrade",
×
687
                        fmt.Sprintf("Error: %s", err),
×
688
                )
×
689
                return fmt.Errorf("failed to rollout deployment after updating secret: %v", err)
×
690
        }
×
691

692
        l.Info("Access token updated in the existing Secret successfully")
1✔
693
        // Raise event
1✔
694
        r.Recorder.Event(
1✔
695
                githubApp,
1✔
696
                "Normal",
1✔
697
                "Updated",
1✔
698
                fmt.Sprintf("Updated access token secret %s/%s", githubApp.Namespace, accessTokenSecret),
1✔
699
        )
1✔
700
        return nil
1✔
701
}
702

703
// Function to get a new access token and create or update a kubernetes secret with it
704
func (r *GithubAppReconciler) createOrUpdateAccessToken(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1✔
705
        l := log.FromContext(ctx)
1✔
706
        githubHost, err := resolveGitHubHost(githubApp.Spec.GithubHost)
1✔
707
        if err != nil {
1✔
NEW
708
                return err
×
NEW
709
        }
×
710

711
        // Try to get private key from local file system
712
        privateKey, privateKeyPath, privateKeyErr := r.getPrivateKey(ctx, githubApp)
1✔
713
        if privateKeyErr != nil {
2✔
714
                return privateKeyErr
1✔
715
        }
1✔
716

717
        // Generate or renew access token
718
        accessToken, expiresAt, err := r.generateAccessToken(
1✔
719
                ctx,
1✔
720
                githubApp.Spec.AppId,
1✔
721
                githubApp.Spec.InstallId,
1✔
722
                privateKey,
1✔
723
                githubHost,
1✔
724
        )
1✔
725
        // if GitHub API request for access token fails
1✔
726
        if err != nil {
1✔
727
                // Delete private key cache
×
728
                l.Error(nil, "Access token request failed, removing cached private key", "file", privateKeyPath)
×
729
                if err := deletePrivateKeyCache(githubApp.Namespace, githubApp.Name); err != nil {
×
730
                        l.Error(err, "failed to remove cached private key")
×
731
                }
×
732
                return fmt.Errorf("failed to generate access token: %v", err)
×
733
        }
734

735
        // Access token Kubernetes secret name
736
        accessTokenSecret := githubApp.Spec.AccessTokenSecret
1✔
737

1✔
738
        // Access token secret key
1✔
739
        accessTokenSecretKey := client.ObjectKey{
1✔
740
                Namespace: githubApp.Namespace,
1✔
741
                Name:      accessTokenSecret,
1✔
742
        }
1✔
743

1✔
744
        // Attempt to retrieve the existing Secret
1✔
745
        existingSecret := &corev1.Secret{}
1✔
746

1✔
747
        if err := r.Get(ctx, accessTokenSecretKey, existingSecret); err != nil {
2✔
748
                // Secret does not exist, create it
1✔
749
                if apierrors.IsNotFound(err) {
2✔
750
                        if err := r.createAccessTokenSecret(ctx, accessTokenSecret, accessToken, expiresAt, githubApp); err != nil {
2✔
751
                                l.Error(err, "failed to create Secret for access token")
1✔
752
                                return err
1✔
753
                        }
1✔
754
                        // secret created successfully, return here
755
                        return nil
1✔
756
                }
757
                // failed to create secret
758
                l.Error(
×
759
                        err,
×
760
                        "failed to get access token secret",
×
761
                        "Namespace", githubApp.Namespace,
×
762
                        "Secret", accessTokenSecret,
×
763
                )
×
764
                return fmt.Errorf("failed to get access token secret: %v", err)
×
765
        }
766

767
        // Secret exists, update it's data
768
        if err := r.updateAccessTokenSecret(ctx, existingSecret, accessTokenSecret, accessToken, expiresAt, githubApp); err != nil {
1✔
769
                l.Error(err, "failed to update Secret for access token")
×
770
                return err
×
771
        }
×
772

773
        return nil
1✔
774
}
775

776
// Function to update GithubApp status field with retry up to maxAttempts attempts
777
func updateGithubAppStatusWithRetry(ctx context.Context, r *GithubAppReconciler, githubApp *githubappv1.GithubApp, expiresAt metav1.Time, maxAttempts int) error {
1✔
778
        attempts := 0
1✔
779
        for {
2✔
780
                attempts++
1✔
781
                githubApp.Status.ExpiresAt = expiresAt
1✔
782
                err := r.Status().Update(ctx, githubApp)
1✔
783
                if err == nil {
2✔
784
                        return nil // Update successful
1✔
785
                }
1✔
786
                if apierrors.IsConflict(err) {
×
787
                        // Conflict error, retry the update
×
788
                        if attempts >= maxAttempts {
×
789
                                return fmt.Errorf("maximum retry attempts reached, failed to update GitHubApp status")
×
790
                        }
×
791
                        // Incremental sleep between attempts
792
                        time.Sleep(time.Duration(attempts*2) * time.Second)
×
793
                        continue
×
794
                }
795
                // Other error, return with the error
796
                return fmt.Errorf("failed to update GitHubApp status: %v", err)
×
797
        }
798
}
799

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

1✔
803
        l := log.FromContext(ctx)
1✔
804

1✔
805
        // Parse private key
1✔
806
        parsedKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
1✔
807
        if err != nil {
1✔
808
                return "", metav1.Time{}, fmt.Errorf("failed to parse private key: %v", err)
×
809
        }
×
810

811
        // Generate JWT
812
        now := time.Now()
1✔
813
        claims := jwt.RegisteredClaims{
1✔
814
                Issuer:    fmt.Sprintf("%d", appID),
1✔
815
                IssuedAt:  jwt.NewNumericDate(now),
1✔
816
                ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // Expiry time is 10 minutes from now
1✔
817
        }
1✔
818
        token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
1✔
819
        signedToken, err := token.SignedString(parsedKey)
1✔
820
        if err != nil {
1✔
821
                return "", metav1.Time{}, fmt.Errorf("failed to sign JWT: %v", err)
×
822
        }
×
823

824
        // Use HTTP client and perform request to get installation token
825
        endpointURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubAPIBaseURL(githubHost), installationID)
1✔
826
        req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpointURL, nil)
1✔
827
        if err != nil {
1✔
828
                return "", metav1.Time{}, fmt.Errorf("failed to create HTTP request: %v", err)
×
829
        }
×
830
        req.Header.Set("Authorization", "Bearer "+signedToken)
1✔
831
        req.Header.Set("Accept", "application/vnd.github+json")
1✔
832

1✔
833
        // Get the access token from GitHub API
1✔
834
        // Retry the request if any rate limit error
1✔
835
        // Return an error if max retries reached
1✔
836
        maxRetries := 5
1✔
837
        for i := 0; i < maxRetries; i++ {
2✔
838
                // Send POST request for access token
1✔
839
                resp, err := r.HTTPClient.Do(req)
1✔
840

1✔
841
                // if error break the loop
1✔
842
                if err != nil {
1✔
843
                        return "", metav1.Time{}, fmt.Errorf("failed to send HTTP post request to GitHub API: %v", err)
×
844
                }
×
845

846
                // Defer closing the response body and check for errors
847
                defer func() {
2✔
848
                        err := resp.Body.Close()
1✔
849
                        if err != nil {
1✔
850
                                l.Error(err, "error closing response body for access token call")
×
851
                        }
×
852
                }()
853

854
                // If response is successful, parse token and expiry
855
                if resp.StatusCode == http.StatusCreated {
2✔
856
                        // Parse response
1✔
857
                        var responseBody Response
1✔
858
                        // if error in body break the loop, return error msg
1✔
859
                        if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
1✔
860
                                return "", metav1.Time{}, fmt.Errorf("failed to parse response body: %v", err)
×
861
                        }
×
862

863
                        // Got token and expiry
864
                        // return and break the loop
865
                        return responseBody.Token, responseBody.ExpiresAt, nil
1✔
866
                }
867

868
                // If response failed due to 403 or 429 (GitHub rate limit errors)
869
                if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
×
870
                        l.Info("Retrying GitHub API access token call")
×
871
                        // Try use retry-after header
×
872
                        retryAfter, err := strconv.Atoi(resp.Header.Get("retry-after"))
×
873
                        if err != nil {
×
874
                                // default to 1s if header not present
×
875
                                retryAfter = 1
×
876
                        }
×
877
                        waitTime := time.Duration(retryAfter) * time.Second
×
878

×
879
                        // Add exponentional backoff
×
880
                        waitTime *= time.Duration(1 << i)
×
881

×
882
                        // Add jitter
×
883
                        waitTime += time.Duration(rand.Intn(500)) * time.Millisecond
×
884

×
885
                        time.Sleep(waitTime)
×
886
                } else {
×
887
                        // If not a rate limit error/any other error
×
888
                        return "", metav1.Time{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
×
889
                }
×
890
        }
891
        // max retries reached return error
892
        return "", metav1.Time{}, fmt.Errorf("failed to get access token after %d retries", maxRetries)
×
893
}
894

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

1✔
899
        // Check if rolloutDeployment field is defined
1✔
900
        if githubApp.Spec.RolloutDeployment == nil || len(githubApp.Spec.RolloutDeployment.Labels) == 0 {
2✔
901
                // No action needed if rolloutDeployment is not defined or no labels are specified
1✔
902
                return nil
1✔
903
        }
1✔
904

905
        // Loop through each label specified in rolloutDeployment.labels and update deployments matching each label
906
        for key, value := range githubApp.Spec.RolloutDeployment.Labels {
2✔
907
                // Create a list options with label selector
1✔
908
                listOptions := &client.ListOptions{
1✔
909
                        Namespace:     githubApp.Namespace,
1✔
910
                        LabelSelector: labels.SelectorFromSet(map[string]string{key: value}),
1✔
911
                }
1✔
912

1✔
913
                // List Deployments with the label selector
1✔
914
                deploymentList := &appsv1.DeploymentList{}
1✔
915
                if err := r.List(ctx, deploymentList, listOptions); err != nil {
1✔
916
                        return fmt.Errorf("failed to list Deployments with label %s=%s: %v", key, value, err)
×
917
                }
×
918

919
                // Trigger rolling upgrade for matching deployments
920
                for _, deployment := range deploymentList.Items {
2✔
921

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

1✔
925
                        // Patch the Deployment
1✔
926
                        if err := r.Update(ctx, &deployment); err != nil {
1✔
927
                                return fmt.Errorf(
×
928
                                        "failed to upgrade deployment %s/%s: %v",
×
929
                                        deployment.Namespace,
×
930
                                        deployment.Name,
×
931
                                        err,
×
932
                                )
×
933
                        }
×
934

935
                        // Log deployment upgrade
936
                        l.Info(
1✔
937
                                "Deployment rolling upgrade triggered",
1✔
938
                                "Name",
1✔
939
                                deployment.Name,
1✔
940
                                "Namespace",
1✔
941
                                deployment.Namespace,
1✔
942
                        )
1✔
943
                        // Raise event
1✔
944
                        r.Recorder.Event(
1✔
945
                                githubApp,
1✔
946
                                "Normal",
1✔
947
                                "Updated",
1✔
948
                                fmt.Sprintf("Updated deployment %s/%s", deployment.Namespace, deployment.Name),
1✔
949
                        )
1✔
950
                }
951
        }
952
        return nil
1✔
953
}
954

955
// Define a predicate function to filter create events for access token secrets
956
func accessTokenSecretPredicate() predicate.Predicate {
1✔
957
        return predicate.Funcs{
1✔
958
                CreateFunc: func(e event.CreateEvent) bool {
2✔
959
                        // Ignore create events for access token secrets
1✔
960
                        return false
1✔
961
                },
1✔
962
        }
963
}
964

965
/*
966
Define a predicate function to filter events for GithubApp objects
967
Check if the status field in ObjectOld is unset return false
968
Check if ExpiresAt is valid in the new GithubApp return false
969
Check if Error status field is cleared return false
970
Ignore status update event for GithubApp
971
*/
972
func githubAppPredicate() predicate.Predicate {
1✔
973
        return predicate.Funcs{
1✔
974
                UpdateFunc: func(e event.UpdateEvent) bool {
2✔
975
                        // Compare the old and new objects
1✔
976
                        oldGithubApp := e.ObjectOld.(*githubappv1.GithubApp)
1✔
977
                        newGithubApp := e.ObjectNew.(*githubappv1.GithubApp)
1✔
978

1✔
979
                        if oldGithubApp.Status.ExpiresAt.IsZero() &&
1✔
980
                                !newGithubApp.Status.ExpiresAt.IsZero() {
2✔
981
                                return false
1✔
982
                        }
1✔
983
                        if oldGithubApp.Status.Error != "" &&
1✔
984
                                newGithubApp.Status.Error == "" {
2✔
985
                                return false
1✔
986
                        }
1✔
987
                        return true
1✔
988
                },
989
        }
990
}
991

992
// Function to get service account and namespace of controller
993
func getServiceAccountAndNamespace(serviceAccountPath string) (string, string, error) {
1✔
994

1✔
995
        // Get KSA mounted in pod
1✔
996
        serviceAccountToken, err := os.ReadFile(serviceAccountPath)
1✔
997
        if err != nil {
1✔
998
                return "", "", fmt.Errorf("failed to read service account token: %v", err)
×
999
        }
×
1000
        // Parse the KSA token
1001
        parsedToken, _, err := new(jwt.Parser).ParseUnverified(string(serviceAccountToken), jwt.MapClaims{})
1✔
1002
        if err != nil {
1✔
1003
                return "", "", fmt.Errorf("failed to parse token: %v", err)
×
1004
        }
×
1005
        // Get the claims
1006
        claims, ok := parsedToken.Claims.(jwt.MapClaims)
1✔
1007
        if !ok {
1✔
1008
                return "", "", fmt.Errorf("failed to parse token claims")
×
1009
        }
×
1010
        // Get kubernetes.io claims
1011
        kubernetesClaims, ok := claims["kubernetes.io"].(map[string]interface{})
1✔
1012
        if !ok {
1✔
1013
                return "", "", fmt.Errorf("failed to assert kubernetes.io claim to map[string]interface{}")
×
1014
        }
×
1015
        // Get serviceaccount claim
1016
        serviceAccountClaims, ok := kubernetesClaims["serviceaccount"].(map[string]interface{})
1✔
1017
        if !ok {
1✔
1018
                return "", "", fmt.Errorf("failed to assert serviceaccount claim to map[string]interface{}")
×
1019
        }
×
1020
        // Get the namespace
1021
        kubernetesNamespace, ok := kubernetesClaims["namespace"].(string)
1✔
1022
        if !ok {
1✔
1023
                return "", "", fmt.Errorf("failed to assert namespace to string")
×
1024
        }
×
1025
        // Get service account name
1026
        serviceAccountName, ok := serviceAccountClaims["name"].(string)
1✔
1027
        if !ok {
1✔
1028
                return "", "", fmt.Errorf("failed to assert service account name to string")
×
1029
        }
×
1030

1031
        return serviceAccountName, kubernetesNamespace, nil
1✔
1032
}
1033

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

1✔
1037
        // Set private key cache path
1✔
1038
        privateKeyCachePath = privateKeyCache
1✔
1039

1✔
1040
        // Get reconcile interval from environment variable or use default value
1✔
1041
        var err error
1✔
1042
        reconcileIntervalStr := os.Getenv("CHECK_INTERVAL")
1✔
1043
        reconcileInterval, err = time.ParseDuration(reconcileIntervalStr)
1✔
1044
        if err != nil {
1✔
1045
                // Handle case where environment variable is not set or invalid
×
1046
                log.Log.Error(err, "failed to set reconcileInterval, defaulting")
×
1047
                reconcileInterval = defaultRequeueAfter
×
1048
        }
×
1049

1050
        // Get time before expiry from environment variable or use default value
1051
        timeBeforeExpiryStr := os.Getenv("EXPIRY_THRESHOLD")
1✔
1052
        timeBeforeExpiry, err = time.ParseDuration(timeBeforeExpiryStr)
1✔
1053
        if err != nil {
1✔
1054
                // Handle case where environment variable is not set or invalid
×
1055
                log.Log.Error(err, "failed to set timeBeforeExpiry, defaulting")
×
1056
                timeBeforeExpiry = defaultTimeBeforeExpiry
×
1057
        }
×
1058

1059
        // Get service account name and namespace
1060
        // Check if tokenPath is provided
1061
        var serviceAccountPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
1✔
1062
        if len(tokenPath) > 0 {
2✔
1063
                serviceAccountPath = tokenPath[0]
1✔
1064
        }
1✔
1065

1066
        serviceAccountName, kubernetesNamespace, err = getServiceAccountAndNamespace(serviceAccountPath)
1✔
1067
        if err != nil {
1✔
1068
                log.Log.Error(err, "failed to get service account and/or namespace of controller")
×
1069
        } else {
1✔
1070
                log.Log.Info("got controller service account and namespace", "service account", serviceAccountName, "namespace", kubernetesNamespace)
1✔
1071
        }
1✔
1072

1073
        return ctrl.NewControllerManagedBy(mgr).
1✔
1074
                // Watch GithubApps
1✔
1075
                For(&githubappv1.GithubApp{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, githubAppPredicate())).
1✔
1076
                // Watch access token secrets owned by GithubApps.
1✔
1077
                Owns(&corev1.Secret{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, accessTokenSecretPredicate())).
1✔
1078
                Complete(r)
1✔
1079
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc