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

samirtahir91 / github-app-operator / 32822721089

25 Aug 2026 07:38AM UTC coverage: 72.086% (+0.2%) from 71.896%
32822721089

push

github

web-flow
feat: exclude rollout labels (#84)

feat: exclude rollout labels

42 of 58 new or added lines in 2 files covered. (72.41%)

767 of 1064 relevant lines covered (72.09%)

0.78 hits per line

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

68.71
/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
        "io"
24
        "math/rand"
25
        "net/http"
26
        "net/url"
27
        "os"
28
        "path/filepath"
29
        "strconv"
30
        "strings"
31
        "sync"
32
        "time"
33

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

220
        return nil
1 ✔
221
}
222

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

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

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

243
        return nil
1 ✔
244
}
245

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

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

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

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

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

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

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

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

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

306
        return nil
1 ✔
307
}
308

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

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

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

329
        return parsedURL.Host, nil
1 ✔
330
}
331

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

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

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

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

1 ✔
355
        // start
1 ✔
356
        maxRetries := 5
1 ✔
357
        for i := 0; i < maxRetries; i++ {
2 ✔
358
                // use context so cancellation can be propagated to the request
1 ✔
359

1 ✔
360
                // Create a new request
1 ✔
361
                ghReq, err := http.NewRequestWithContext(ctx, "GET", endpointURL, nil)
1 ✔
362
                if err != nil {
1 ✔
NEW
363
                        l.Error(err, "error creating request to GitHub API for rate limit")
×
NEW
364
                        return false
×
NEW
365
                }
×
366
                // Add the access token to the request header
367
                ghReq.Header.Set("Authorization", "token "+accessToken)
1 ✔
368

1 ✔
369
                // Send POST request for access token
1 ✔
370
                resp, err := r.HTTPClient.Do(ghReq)
1 ✔
371

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

378
                // close response
379
                closeBody := func() {
2 ✔
380
                        _, _ = io.Copy(io.Discard, resp.Body)
1 ✔
381
                        _ = resp.Body.Close()
1 ✔
382
                }
1 ✔
383

384
                // Check if the response status code is 200 (OK)
385
                if resp.StatusCode == http.StatusOK {
2 ✔
386
                        // Decode the response body into the struct
1 ✔
387
                        var result RateLimitInfo
1 ✔
388
                        err = json.NewDecoder(resp.Body).Decode(&result)
1 ✔
389
                        closeBody()
1 ✔
390
                        if err != nil {
1 ✔
391
                                l.Error(err, "error decoding response body for rate limit")
×
392
                                return false
×
393
                        }
×
394

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

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

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

409
                // If response failed due to 403 or 429 (GitHub rate limit errors)
410
                if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
1 ✔
NEW
411
                        closeBody()
×
412
                        // Try use retry-after header
×
NEW
413
                        l.Info("Retrying GitHub API rate limit call")
×
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

×
NEW
427
                        // respect context cancellation during sleep
×
NEW
428
                        select {
×
NEW
429
                        case <-ctx.Done():
×
NEW
430
                                return false
×
NEW
431
                        case <-time.After(waitTime):
×
432
                        }
433
                } else {
1 ✔
434
                        closeBody()
1 ✔
435
                        l.Info("Access token is invalid, will renew", "API Response code", resp.Status)
1 ✔
436

1 ✔
437
                        return false
1 ✔
438

1 ✔
439
                }
1 ✔
440
        }
441
        // max retries reached return error
NEW
442
        l.Error(nil, "error sending request to GitHub API for rate limit: max retries reached")
×
443
        return false
×
444
}
445

446
// Function to check expiry and requeue
447
func checkExpiryAndRequeue(ctx context.Context, githubApp *githubappv1.GithubApp) ctrl.Result {
1 ✔
448
        l := log.FromContext(ctx)
1 ✔
449

1 ✔
450
        // Get the expiresAt status field
1 ✔
451
        expiresAt := githubApp.Status.ExpiresAt.Time
1 ✔
452

1 ✔
453
        // Log the next expiry time
1 ✔
454
        l.Info("Next expiry time:", "expiresAt", expiresAt)
1 ✔
455

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

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

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

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

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

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

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

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

×
504
        // Get the secret name for the GCP Secret
×
505
        secretName := githubApp.Spec.GcpPrivateKeySecret
×
506

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

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

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

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

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

1 ✔
544
        var privateKey []byte
1 ✔
545
        var privateKeyPath string
1 ✔
546
        var privateKeyErr error
1 ✔
547

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

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

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

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

605
        return privateKey, privateKeyPath, nil
1 ✔
606
}
607

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

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

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

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

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

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

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

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

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

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

739
        // Access token Kubernetes secret name
740
        accessTokenSecret := githubApp.Spec.AccessTokenSecret
1 ✔
741

1 ✔
742
        // Access token secret key
1 ✔
743
        accessTokenSecretKey := client.ObjectKey{
1 ✔
744
                Namespace: githubApp.Namespace,
1 ✔
745
                Name:      accessTokenSecret,
1 ✔
746
        }
1 ✔
747

1 ✔
748
        // Attempt to retrieve the existing Secret
1 ✔
749
        existingSecret := &corev1.Secret{}
1 ✔
750

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

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

777
        return nil
1 ✔
778
}
779

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

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

1 ✔
807
        l := log.FromContext(ctx)
1 ✔
808

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

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

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

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

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

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

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

867
                        // Got token and expiry
868
                        // return and break the loop
869
                        return responseBody.Token, responseBody.ExpiresAt, nil
1 ✔
870
                }
871

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

×
883
                        // Add exponentional backoff
×
884
                        waitTime *= time.Duration(1 << i)
×
885

×
886
                        // Add jitter
×
887
                        waitTime += time.Duration(rand.Intn(500)) * time.Millisecond
×
888

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

899
// Function to upgrade deployments as per `spec.rolloutDeployment.labels` in GithubApp (in the same namespace)
900
// Deployments matching any label in `spec.rolloutDeployment.excludeLabels` are skipped to avoid conflicting rollout restarts
901
func (r *GithubAppReconciler) rolloutDeployment(ctx context.Context, githubApp *githubappv1.GithubApp) error {
1 ✔
902
        l := log.FromContext(ctx)
1 ✔
903

1 ✔
904
        // Check if rolloutDeployment field is defined
1 ✔
905
        if githubApp.Spec.RolloutDeployment == nil || len(githubApp.Spec.RolloutDeployment.Labels) == 0 {
2 ✔
906
                // No action needed if rolloutDeployment is not defined or no labels are specified
1 ✔
907
                return nil
1 ✔
908
        }
1 ✔
909

910
        excludeLabels := githubApp.Spec.RolloutDeployment.ExcludeLabels
1 ✔
911

1 ✔
912
        // Loop through each label specified in rolloutDeployment.labels and update deployments matching each label
1 ✔
913
        for key, value := range githubApp.Spec.RolloutDeployment.Labels {
2 ✔
914
                // Create a list options with label selector
1 ✔
915
                listOptions := &client.ListOptions{
1 ✔
916
                        Namespace:     githubApp.Namespace,
1 ✔
917
                        LabelSelector: labels.SelectorFromSet(map[string]string{key: value}),
1 ✔
918
                }
1 ✔
919

1 ✔
920
                // List Deployments with the label selector
1 ✔
921
                deploymentList := &appsv1.DeploymentList{}
1 ✔
922
                if err := r.List(ctx, deploymentList, listOptions); err != nil {
1 ✔
923
                        return fmt.Errorf("failed to list Deployments with label %s=%s: %v", key, value, err)
×
924
                }
×
925

926
                // Trigger rolling upgrade for matching deployments
927
                for _, deployment := range deploymentList.Items {
2 ✔
928

1 ✔
929
                        // Skip deployments that carry any excludeLabels to avoid conflicting rollout restarts
1 ✔
930
                        if hasAnyMatchingLabel(deployment.Labels, excludeLabels) {
2 ✔
931
                                l.Info(
1 ✔
932
                                        "Skipping deployment rollout, matched excludeLabels",
1 ✔
933
                                        "Name", deployment.Name,
1 ✔
934
                                        "Namespace", deployment.Namespace,
1 ✔
935
                                )
1 ✔
936
                                continue
1 ✔
937
                        }
938

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

1 ✔
942
                        // Patch the Deployment with retry, log errored deployments and continue with the next one
1 ✔
943
                        err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
2 ✔
944
                                return r.Update(ctx, &deployment)
1 ✔
945
                        })
1 ✔
946
                        if err != nil {
1 ✔
NEW
947
                                l.Error(
×
NEW
948
                                        err,
×
NEW
949
                                        "failed to upgrade deployment",
×
NEW
950
                                        "Namespace",
×
951
                                        deployment.Namespace,
×
NEW
952
                                        "Name",
×
953
                                        deployment.Name,
×
954
                                )
×
955
                        }
×
956

957
                        // Log deployment upgrade
958
                        l.Info(
1 ✔
959
                                "Deployment rolling upgrade triggered",
1 ✔
960
                                "Name",
1 ✔
961
                                deployment.Name,
1 ✔
962
                                "Namespace",
1 ✔
963
                                deployment.Namespace,
1 ✔
964
                        )
1 ✔
965
                        // Raise event
1 ✔
966
                        r.Recorder.Event(
1 ✔
967
                                githubApp,
1 ✔
968
                                "Normal",
1 ✔
969
                                "Updated",
1 ✔
970
                                fmt.Sprintf("Updated deployment %s/%s", deployment.Namespace, deployment.Name),
1 ✔
971
                        )
1 ✔
972
                }
973
        }
974
        return nil
1 ✔
975
}
976

977
// Function to check if labelSet contains any key/value pair present in matchLabels
978
func hasAnyMatchingLabel(labelSet map[string]string, matchLabels map[string]string) bool {
1 ✔
979
        for key, value := range matchLabels {
2 ✔
980
                if v, ok := labelSet[key]; ok && v == value {
2 ✔
981
                        return true
1 ✔
982
                }
1 ✔
983
        }
984
        return false
1 ✔
985
}
986

987
// Define a predicate function to filter create events for access token secrets
988
func accessTokenSecretPredicate() predicate.Predicate {
1 ✔
989
        return predicate.Funcs{
1 ✔
990
                CreateFunc: func(e event.CreateEvent) bool {
2 ✔
991
                        // Ignore create events for access token secrets
1 ✔
992
                        return false
1 ✔
993
                },
1 ✔
994
        }
995
}
996

997
/*
998
Define a predicate function to filter events for GithubApp objects
999
Check if the status field in ObjectOld is unset return false
1000
Check if ExpiresAt is valid in the new GithubApp return false
1001
Check if Error status field is cleared return false
1002
Ignore status update event for GithubApp
1003
*/
1004
func githubAppPredicate() predicate.Predicate {
1 ✔
1005
        return predicate.Funcs{
1 ✔
1006
                UpdateFunc: func(e event.UpdateEvent) bool {
2 ✔
1007
                        // Compare the old and new objects
1 ✔
1008
                        oldGithubApp := e.ObjectOld.(*githubappv1.GithubApp)
1 ✔
1009
                        newGithubApp := e.ObjectNew.(*githubappv1.GithubApp)
1 ✔
1010

1 ✔
1011
                        if oldGithubApp.Status.ExpiresAt.IsZero() &&
1 ✔
1012
                                !newGithubApp.Status.ExpiresAt.IsZero() {
2 ✔
1013
                                return false
1 ✔
1014
                        }
1 ✔
1015
                        if oldGithubApp.Status.Error != "" &&
1 ✔
1016
                                newGithubApp.Status.Error == "" {
2 ✔
1017
                                return false
1 ✔
1018
                        }
1 ✔
1019
                        return true
1 ✔
1020
                },
1021
        }
1022
}
1023

1024
// Function to get service account and namespace of controller
1025
func getServiceAccountAndNamespace(serviceAccountPath string) (string, string, error) {
1 ✔
1026

1 ✔
1027
        // Get KSA mounted in pod
1 ✔
1028
        serviceAccountToken, err := os.ReadFile(serviceAccountPath)
1 ✔
1029
        if err != nil {
1 ✔
1030
                return "", "", fmt.Errorf("failed to read service account token: %v", err)
×
1031
        }
×
1032
        // Parse the KSA token
1033
        parsedToken, _, err := new(jwt.Parser).ParseUnverified(string(serviceAccountToken), jwt.MapClaims{})
1 ✔
1034
        if err != nil {
1 ✔
1035
                return "", "", fmt.Errorf("failed to parse token: %v", err)
×
1036
        }
×
1037
        // Get the claims
1038
        claims, ok := parsedToken.Claims.(jwt.MapClaims)
1 ✔
1039
        if !ok {
1 ✔
1040
                return "", "", fmt.Errorf("failed to parse token claims")
×
1041
        }
×
1042
        // Get kubernetes.io claims
1043
        kubernetesClaims, ok := claims["kubernetes.io"].(map[string]interface{})
1 ✔
1044
        if !ok {
1 ✔
1045
                return "", "", fmt.Errorf("failed to assert kubernetes.io claim to map[string]interface{}")
×
1046
        }
×
1047
        // Get serviceaccount claim
1048
        serviceAccountClaims, ok := kubernetesClaims["serviceaccount"].(map[string]interface{})
1 ✔
1049
        if !ok {
1 ✔
1050
                return "", "", fmt.Errorf("failed to assert serviceaccount claim to map[string]interface{}")
×
1051
        }
×
1052
        // Get the namespace
1053
        kubernetesNamespace, ok := kubernetesClaims["namespace"].(string)
1 ✔
1054
        if !ok {
1 ✔
1055
                return "", "", fmt.Errorf("failed to assert namespace to string")
×
1056
        }
×
1057
        // Get service account name
1058
        serviceAccountName, ok := serviceAccountClaims["name"].(string)
1 ✔
1059
        if !ok {
1 ✔
1060
                return "", "", fmt.Errorf("failed to assert service account name to string")
×
1061
        }
×
1062

1063
        return serviceAccountName, kubernetesNamespace, nil
1 ✔
1064
}
1065

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

1 ✔
1069
        // Set private key cache path
1 ✔
1070
        privateKeyCachePath = privateKeyCache
1 ✔
1071

1 ✔
1072
        // Get reconcile interval from environment variable or use default value
1 ✔
1073
        var err error
1 ✔
1074
        reconcileIntervalStr := os.Getenv("CHECK_INTERVAL")
1 ✔
1075
        reconcileInterval, err = time.ParseDuration(reconcileIntervalStr)
1 ✔
1076
        if err != nil {
1 ✔
1077
                // Handle case where environment variable is not set or invalid
×
1078
                log.Log.Error(err, "failed to set reconcileInterval, defaulting")
×
1079
                reconcileInterval = defaultRequeueAfter
×
1080
        }
×
1081

1082
        // Get time before expiry from environment variable or use default value
1083
        timeBeforeExpiryStr := os.Getenv("EXPIRY_THRESHOLD")
1 ✔
1084
        timeBeforeExpiry, err = time.ParseDuration(timeBeforeExpiryStr)
1 ✔
1085
        if err != nil {
1 ✔
1086
                // Handle case where environment variable is not set or invalid
×
1087
                log.Log.Error(err, "failed to set timeBeforeExpiry, defaulting")
×
1088
                timeBeforeExpiry = defaultTimeBeforeExpiry
×
1089
        }
×
1090

1091
        // Get service account name and namespace
1092
        // Check if tokenPath is provided
1093
        var serviceAccountPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
1 ✔
1094
        if len(tokenPath) > 0 {
2 ✔
1095
                serviceAccountPath = tokenPath[0]
1 ✔
1096
        }
1 ✔
1097

1098
        serviceAccountName, kubernetesNamespace, err = getServiceAccountAndNamespace(serviceAccountPath)
1 ✔
1099
        if err != nil {
1 ✔
1100
                log.Log.Error(err, "failed to get service account and/or namespace of controller")
×
1101
        } else {
1 ✔
1102
                log.Log.Info("got controller service account and namespace", "service account", serviceAccountName, "namespace", kubernetesNamespace)
1 ✔
1103
        }
1 ✔
1104

1105
        return ctrl.NewControllerManagedBy(mgr).
1 ✔
1106
                // Watch GithubApps
1 ✔
1107
                For(&githubappv1.GithubApp{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, githubAppPredicate())).
1 ✔
1108
                // Watch access token secrets owned by GithubApps.
1 ✔
1109
                Owns(&corev1.Secret{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}, accessTokenSecretPredicate())).
1 ✔
1110
                Complete(r)
1 ✔
1111
}
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