Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +102,7 @@ metadata:
spec:
appId: <your-github-app-id>
installId: <your-github-app-installation-id>
githubHost: <your-github-hostname> # Optional, defaults to github.com
privateKeySecret: <your-private-key-secret-name> # If using Kubernetes secret
googlePrivateKeySecret: <your-google-secret-path> # If using GCP Secret Manager
vaultPrivateKey: # If using Hashicorp Vault
Expand Down
1 change: 1 addition & 0 deletions api/v1/githubapp_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions charts/github-app-operator/templates/githubapp-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ spec:
type: string
appId:
type: integer
githubHost:
type: string
googlePrivateKeySecret:
type: string
installId:
Expand Down
2 changes: 2 additions & 0 deletions config/crd/bases/githubapp.samir.io_githubapps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ spec:
type: string
appId:
type: integer
githubHost:
type: string
googlePrivateKeySecret:
type: string
installId:
Expand Down
1 change: 1 addition & 0 deletions example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ metadata:
spec:
appId: 857468
installId: 48531286
githubHost: github.com
privateKeySecret: github-app-secret
accessTokenSecret: github-app-access-token-123123
104 changes: 104 additions & 0 deletions internal/controller/github_host_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
61 changes: 53 additions & 8 deletions internal/controller/githubapp_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import (
"fmt"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
Expand Down
Loading