From f4dd4394e285fd2fe6874239e0f7273ba7314bba Mon Sep 17 00:00:00 2001 From: samirtahir91 Date: Thu, 21 May 2026 12:21:37 +0100 Subject: [PATCH] feat: Add support for custom github hosts --- README.md | 3 +- api/v1/githubapp_types.go | 1 + .../templates/githubapp-crd.yaml | 2 + .../bases/githubapp.samir.io_githubapps.yaml | 2 + example.yaml | 1 + internal/controller/github_host_test.go | 104 ++++++++++++++++++ internal/controller/githubapp_controller.go | 61 ++++++++-- 7 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 internal/controller/github_host_test.go diff --git a/README.md b/README.md index 72c4546..786e728 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The `github-app-operator` is a Kubernetes operator that generates an access toke ### Key Features - Uses a custom resource `GithubApp` in your destination namespace. -- Reads `appId`, `installId`, and either `privateKeySecret`, `googlePrivateKeySecret` or `vaultPrivateKey` defined in a `GithubApp` resource to request an access token from GitHub. +- Reads `appId`, `installId`, optional `githubHost`, and either `privateKeySecret`, `googlePrivateKeySecret` or `vaultPrivateKey` defined in a `GithubApp` resource to request an access token from GitHub. - Stores the access token in a secret specified by `accessTokenSecret`. ### Private Key Retrieval Options @@ -102,6 +102,7 @@ metadata: spec: appId: installId: + githubHost: # Optional, defaults to github.com privateKeySecret: # If using Kubernetes secret googlePrivateKeySecret: # If using GCP Secret Manager vaultPrivateKey: # If using Hashicorp Vault diff --git a/api/v1/githubapp_types.go b/api/v1/githubapp_types.go index dd171ac..0bc2cc2 100644 --- a/api/v1/githubapp_types.go +++ b/api/v1/githubapp_types.go @@ -24,6 +24,7 @@ import ( type GithubAppSpec struct { AppId int `json:"appId"` InstallId int `json:"installId"` + GithubHost string `json:"githubHost,omitempty"` PrivateKeySecret string `json:"privateKeySecret,omitempty"` RolloutDeployment *RolloutDeploymentSpec `json:"rolloutDeployment,omitempty"` VaultPrivateKey *VaultPrivateKeySpec `json:"vaultPrivateKey,omitempty"` diff --git a/charts/github-app-operator/templates/githubapp-crd.yaml b/charts/github-app-operator/templates/githubapp-crd.yaml index feae126..a34d95f 100644 --- a/charts/github-app-operator/templates/githubapp-crd.yaml +++ b/charts/github-app-operator/templates/githubapp-crd.yaml @@ -74,6 +74,8 @@ spec: type: string appId: type: integer + githubHost: + type: string googlePrivateKeySecret: type: string installId: diff --git a/config/crd/bases/githubapp.samir.io_githubapps.yaml b/config/crd/bases/githubapp.samir.io_githubapps.yaml index 58e9257..24ca15c 100644 --- a/config/crd/bases/githubapp.samir.io_githubapps.yaml +++ b/config/crd/bases/githubapp.samir.io_githubapps.yaml @@ -59,6 +59,8 @@ spec: type: string appId: type: integer + githubHost: + type: string googlePrivateKeySecret: type: string installId: diff --git a/example.yaml b/example.yaml index 534c274..8b199a8 100644 --- a/example.yaml +++ b/example.yaml @@ -6,5 +6,6 @@ metadata: spec: appId: 857468 installId: 48531286 + githubHost: github.com privateKeySecret: github-app-secret accessTokenSecret: github-app-access-token-123123 \ No newline at end of file diff --git a/internal/controller/github_host_test.go b/internal/controller/github_host_test.go new file mode 100644 index 0000000..89cc510 --- /dev/null +++ b/internal/controller/github_host_test.go @@ -0,0 +1,104 @@ +package controller + +import "testing" + +func TestResolveGitHubHost(t *testing.T) { + tests := []struct { + name string + input string + wantHost string + expectFail bool + }{ + { + name: "empty defaults to github.com", + input: "", + wantHost: defaultGitHubHost, + }, + { + name: "plain github.com host", + input: "github.com", + wantHost: "github.com", + }, + { + name: "github.com with scheme", + input: "https://github.com", + wantHost: "github.com", + }, + { + name: "enterprise host", + input: "ghe.example.com", + wantHost: "ghe.example.com", + }, + { + name: "enterprise host with scheme and port", + input: "https://ghe.example.com:8443", + wantHost: "ghe.example.com:8443", + }, + { + name: "path is invalid", + input: "ghe.example.com/api/v3", + expectFail: true, + }, + { + name: "malformed URL is invalid", + input: "https://", + expectFail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveGitHubHost(tt.input) + if tt.expectFail { + if err == nil { + t.Fatalf("expected an error for input %q", tt.input) + } + return + } + + if err != nil { + t.Fatalf("unexpected error for input %q: %v", tt.input, err) + } + if got != tt.wantHost { + t.Fatalf("resolveGitHubHost(%q) = %q, want %q", tt.input, got, tt.wantHost) + } + }) + } +} + +func TestGitHubAPIBaseURL(t *testing.T) { + tests := []struct { + name string + host string + expected string + }{ + { + name: "github.com maps to public api host", + host: "github.com", + expected: "https://api.github.com", + }, + { + name: "api.github.com preserved", + host: "api.github.com", + expected: "https://api.github.com", + }, + { + name: "enterprise host maps to api v3 path", + host: "ghe.example.com", + expected: "https://ghe.example.com/api/v3", + }, + { + name: "enterprise host with port maps to api v3 path", + host: "ghe.example.com:8443", + expected: "https://ghe.example.com:8443/api/v3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := githubAPIBaseURL(tt.host); got != tt.expected { + t.Fatalf("githubAPIBaseURL(%q) = %q, want %q", tt.host, got, tt.expected) + } + }) + } +} diff --git a/internal/controller/githubapp_controller.go b/internal/controller/githubapp_controller.go index c8a1d9c..5f2ff77 100644 --- a/internal/controller/githubapp_controller.go +++ b/internal/controller/githubapp_controller.go @@ -22,9 +22,11 @@ import ( "fmt" "math/rand" "net/http" + "net/url" "os" "path/filepath" "strconv" + "strings" "sync" "time" @@ -94,7 +96,10 @@ var ( ) const ( - gitUsername = "not-used" + gitUsername = "not-used" + defaultGitHubHost = "github.com" + defaultGitHubAPIHost = "api.github.com" + defaultGitHubAPIScheme = "https" ) // +kubebuilder:rbac:groups=githubapp.samir.io,resources=githubapps,verbs=get;list;watch;create;update;patch;delete @@ -240,6 +245,10 @@ func (r *GithubAppReconciler) updateStatusWithError(ctx context.Context, githubA func (r *GithubAppReconciler) checkExpiryAndUpdateAccessToken(ctx context.Context, githubApp *githubappv1.GithubApp) error { l := log.FromContext(ctx) + githubHost, err := resolveGitHubHost(githubApp.Spec.GithubHost) + if err != nil { + return err + } // Get the expiresAt status field expiresAt := githubApp.Status.ExpiresAt.Time @@ -276,7 +285,7 @@ func (r *GithubAppReconciler) checkExpiryAndUpdateAccessToken(ctx context.Contex username := string(accessTokenSecret.Data["username"]) // Check if the access token is a valid github token via gh api auth - if !r.isAccessTokenValid(ctx, username, accessToken) { + if !r.isAccessTokenValid(ctx, username, accessToken, githubHost) { // If accessToken is invalid, generate or update access token return r.createOrUpdateAccessToken(ctx, githubApp) } @@ -295,8 +304,39 @@ func (r *GithubAppReconciler) checkExpiryAndUpdateAccessToken(ctx context.Contex return nil } +// Function to resolve GitHub host from spec and apply default when omitted. +func resolveGitHubHost(githubHost string) (string, error) { + host := strings.TrimSpace(githubHost) + if host == "" { + return defaultGitHubHost, nil + } + + // Accept hostnames with or without a URL scheme. + if !strings.Contains(host, "://") { + host = fmt.Sprintf("%s://%s", defaultGitHubAPIScheme, host) + } + + parsedURL, err := url.Parse(host) + if err != nil || parsedURL.Host == "" { + return "", fmt.Errorf("invalid githubHost %q: must be a valid hostname", githubHost) + } + if parsedURL.Path != "" && parsedURL.Path != "/" { + return "", fmt.Errorf("invalid githubHost %q: must not include a URL path", githubHost) + } + + return parsedURL.Host, nil +} + +// Function to return the REST API base URL from a GitHub host. +func githubAPIBaseURL(githubHost string) string { + if githubHost == defaultGitHubHost || githubHost == defaultGitHubAPIHost { + return fmt.Sprintf("%s://%s", defaultGitHubAPIScheme, defaultGitHubAPIHost) + } + return fmt.Sprintf("%s://%s/api/v3", defaultGitHubAPIScheme, githubHost) +} + // Function to check if the access token is valid by making a request to GitHub API -func (r *GithubAppReconciler) isAccessTokenValid(ctx context.Context, username string, accessToken string) bool { +func (r *GithubAppReconciler) isAccessTokenValid(ctx context.Context, username string, accessToken string, githubHost string) bool { l := log.FromContext(ctx) // If username has been modified, renew the secret @@ -308,10 +348,10 @@ func (r *GithubAppReconciler) isAccessTokenValid(ctx context.Context, username s } // GitHub API endpoint for rate limit information - url := "https://api.github.com/rate_limit" + endpointURL := fmt.Sprintf("%s/rate_limit", githubAPIBaseURL(githubHost)) // Create a new request - ghReq, err := http.NewRequest("GET", url, nil) + ghReq, err := http.NewRequest("GET", endpointURL, nil) if err != nil { l.Error(err, "error creating request to GitHub API for rate limit") return false @@ -663,6 +703,10 @@ func (r *GithubAppReconciler) updateAccessTokenSecret(ctx context.Context, exist // Function to get a new access token and create or update a kubernetes secret with it func (r *GithubAppReconciler) createOrUpdateAccessToken(ctx context.Context, githubApp *githubappv1.GithubApp) error { l := log.FromContext(ctx) + githubHost, err := resolveGitHubHost(githubApp.Spec.GithubHost) + if err != nil { + return err + } // Try to get private key from local file system privateKey, privateKeyPath, privateKeyErr := r.getPrivateKey(ctx, githubApp) @@ -676,6 +720,7 @@ func (r *GithubAppReconciler) createOrUpdateAccessToken(ctx context.Context, git githubApp.Spec.AppId, githubApp.Spec.InstallId, privateKey, + githubHost, ) // if GitHub API request for access token fails if err != nil { @@ -753,7 +798,7 @@ func updateGithubAppStatusWithRetry(ctx context.Context, r *GithubAppReconciler, } // Function to generate new access token for gh app -func (r *GithubAppReconciler) generateAccessToken(ctx context.Context, appID int, installationID int, privateKey []byte) (string, metav1.Time, error) { +func (r *GithubAppReconciler) generateAccessToken(ctx context.Context, appID int, installationID int, privateKey []byte, githubHost string) (string, metav1.Time, error) { l := log.FromContext(ctx) @@ -777,8 +822,8 @@ func (r *GithubAppReconciler) generateAccessToken(ctx context.Context, appID int } // Use HTTP client and perform request to get installation token - url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID) - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil) + endpointURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubAPIBaseURL(githubHost), installationID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpointURL, nil) if err != nil { return "", metav1.Time{}, fmt.Errorf("failed to create HTTP request: %v", err) }