From 061f997b08486fa1a87b9ee260efff375e376a67 Mon Sep 17 00:00:00 2001 From: Andrew Block Date: Thu, 27 Nov 2025 23:33:37 -0600 Subject: [PATCH] Added support for GitHub App auth Signed-off-by: Andrew Block --- go.mod | 2 +- hack/build.sh | 2 +- internal/controller/checkout.go | 103 ++++++++++++++- internal/controller/utils.go | 11 ++ internal/controller/utils_test.go | 204 ++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index f1609a207..cadb746d0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( code.gitea.io/sdk/gitea v0.22.1 github.com/Masterminds/semver/v3 v3.4.0 github.com/argoproj-labs/argocd-operator v0.15.0 + github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/go-errors/errors v1.5.1 github.com/go-git/go-git/v5 v5.16.4 github.com/go-logr/logr v1.4.3 @@ -51,7 +52,6 @@ require ( github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/bombsimon/logrusr/v4 v4.1.0 // indirect - github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 // indirect github.com/casbin/casbin/v2 v2.127.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/hack/build.sh b/hack/build.sh index fa95c0f3a..8f969e35f 100755 --- a/hack/build.sh +++ b/hack/build.sh @@ -5,7 +5,7 @@ GIT_VERSION=$(git describe --always --tags || true) VERSION=${CI_UPSTREAM_VERSION:-${GIT_VERSION}} GIT_COMMIT=$(git rev-list -1 HEAD || true) COMMIT=${CI_UPSTREAM_COMMIT:-${GIT_COMMIT}} -BUILD_DATE=$(date --utc -Iseconds) +BUILD_DATE=$(TZ=UTC date -Iseconds) LDFLAGS="-s -w " REPO="github.com/hybrid-cloud-patterns/patterns-operator" diff --git a/internal/controller/checkout.go b/internal/controller/checkout.go index 28a4d022a..b14110cc6 100644 --- a/internal/controller/checkout.go +++ b/internal/controller/checkout.go @@ -17,11 +17,13 @@ limitations under the License. package controllers import ( + "context" "fmt" nethttp "net/http" "os" "regexp" "strings" + "time" "path/filepath" @@ -35,17 +37,21 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/bradleyfalzon/ghinstallation/v2" + argogit "github.com/argoproj/argo-cd/v3/util/git" ) type GitAuthenticationBackend uint const ( - GitAuthNone GitAuthenticationBackend = 0 - GitAuthPassword GitAuthenticationBackend = 1 - GitAuthSsh GitAuthenticationBackend = 2 + GitAuthNone GitAuthenticationBackend = 0 + GitAuthPassword GitAuthenticationBackend = 1 + GitAuthSsh GitAuthenticationBackend = 2 + GitAuthGitHubApp GitAuthenticationBackend = 3 ) +const ContextTimeout = 15 * time.Second const GitCustomCAFile = "/tmp/vp-git-cas.pem" const GitHEAD = "HEAD" const VPTmpFolder = "vp" @@ -176,7 +182,7 @@ func checkoutRevision(fullClient kubernetes.Interface, gitOps GitOperations, url if repo == nil { // we mocked the above OpenRepository return nil } - foptions, err := getFetchOptions(url, secret) + foptions, err := getFetchOptions(fullClient, url, secret) if err != nil { return err } @@ -235,7 +241,7 @@ func cloneRepo(fullClient kubernetes.Interface, gitOps GitOperations, url, direc } fmt.Printf("git clone %s into %s\n", url, directory) - options, err := getCloneOptions(url, secret) + options, err := getCloneOptions(fullClient, url, secret) if err != nil { return err } @@ -259,7 +265,7 @@ func cloneRepo(fullClient kubernetes.Interface, gitOps GitOperations, url, direc return nil } -func getFetchOptions(url string, secret map[string][]byte) (*git.FetchOptions, error) { +func getFetchOptions(fullClient kubernetes.Interface, url string, secret map[string][]byte) (*git.FetchOptions, error) { var foptions = &git.FetchOptions{ RemoteName: "origin", Force: true, @@ -275,12 +281,19 @@ func getFetchOptions(url string, secret map[string][]byte) (*git.FetchOptions, e return nil, err } foptions.Auth = publicKey + case GitAuthGitHubApp: + gitHubAppAuth, err := getGitHubAppAuth(fullClient, secret) + if err != nil { + return nil, err + } + + foptions.Auth = gitHubAppAuth } return foptions, nil } -func getCloneOptions(url string, secret map[string][]byte) (*git.CloneOptions, error) { +func getCloneOptions(fullClient kubernetes.Interface, url string, secret map[string][]byte) (*git.CloneOptions, error) { // Clone the given repository to the given directory var options = &git.CloneOptions{ URL: url, @@ -300,6 +313,13 @@ func getCloneOptions(url string, secret map[string][]byte) (*git.CloneOptions, e return nil, err } options.Auth = publicKey + case GitAuthGitHubApp: + gitHubAppAuth, err := getGitHubAppAuth(fullClient, secret) + if err != nil { + return nil, err + } + + options.Auth = gitHubAppAuth } return options, nil @@ -333,6 +353,62 @@ func getSshPublicKey(url string, secret map[string][]byte) (*ssh.PublicKeys, err return publicKey, nil } +func getGitHubAppAuthTransport(fullClient kubernetes.Interface, secret map[string][]byte) (*ghinstallation.Transport, error) { + baseURL := "https://api.github.com" + + if githubAppEnterpriseBaseUrl := getField(secret, "githubAppEnterpriseBaseUrl"); githubAppEnterpriseBaseUrl != nil { + baseURL = strings.TrimSuffix(string(githubAppEnterpriseBaseUrl), "/") + } + + transport := getHTTPSTransport(fullClient) + + githubAppID, err := IntOrZero(secret, "githubAppID") + if err != nil { + return nil, err + } + + githubAppInstallationID, err := IntOrZero(secret, "githubAppInstallationID") + if err != nil { + return nil, err + } + + itr, err := ghinstallation.New(transport, + githubAppID, + githubAppInstallationID, + getField(secret, "githubAppPrivateKey"), + ) + + if err != nil { + return nil, fmt.Errorf("failed to initialize GitHub installation transport: %w", err) + } + + itr.BaseURL = baseURL + + return itr, nil +} + +func getGitHubAppAuth(fullClient kubernetes.Interface, secret map[string][]byte) (*http.BasicAuth, error) { + ctx, cancel := context.WithTimeout(context.Background(), ContextTimeout) + defer cancel() + + // Obtain GitHub Transport + itr, err := getGitHubAppAuthTransport(fullClient, secret) + if err != nil { + return nil, err + } + accessToken, err := itr.Token(ctx) + if err != nil { + return nil, fmt.Errorf("could not get GitHub App installation token: %w", err) + } + + auth := &http.BasicAuth{ + Username: "x-access-token", + Password: accessToken, + } + + return auth, nil +} + // This returns the user prefix in git urls like: // git@github.com:/foo/bar or "" when not found func getUserFromURL(url string) string { @@ -362,14 +438,27 @@ func repoHash(directory string) (string, error) { // if a secret has // returns "" if a secret could not be parse, "ssh" if it is an ssh auth, and "password" if a username + pass auth func detectGitAuthType(secret map[string][]byte) GitAuthenticationBackend { + // SSH if _, ok := secret["sshPrivateKey"]; ok { return GitAuthSsh } + + // Username + Password _, hasUser := secret["username"] _, hasPassword := secret["password"] if hasUser && hasPassword { return GitAuthPassword } + + // GitHub App + _, hasGithubAppID := secret["githubAppID"] + _, hasGithubAppInstallationID := secret["githubAppInstallationID"] + _, hasGithubAppPrivateKey := secret["githubAppPrivateKey"] + if hasGithubAppID && hasGithubAppInstallationID && hasGithubAppPrivateKey { + return GitAuthGitHubApp + } + + // None return GitAuthNone } diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 7ad533a10..72122b343 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -27,6 +27,7 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "crypto/rand" @@ -404,3 +405,13 @@ func IsCommonSlimmed(patternPath string) bool { } return true } + +// IntOrZero retrieves an integer value from a map by key. +func IntOrZero(secret map[string][]byte, key string) (int64, error) { + val, present := secret[key] + if !present { + return 0, nil + } + + return strconv.ParseInt(string(val), 10, 64) +} diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go index ffca2d16a..a18554b68 100644 --- a/internal/controller/utils_test.go +++ b/internal/controller/utils_test.go @@ -457,6 +457,210 @@ var _ = Describe("GetPatternConditionByType", func() { }) }) +var _ = Describe("IntOrZero", func() { + var ( + secret map[string][]byte + key string + expectedResult int64 + ) + + Context("when the secret map is nil", func() { + BeforeEach(func() { + secret = nil + expectedResult = int64(0) + }) + It("should return 0 for the result", func() { + result, err := IntOrZero(secret, key) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when an invalid key is selected from the secret map", func() { + BeforeEach(func() { + key = "key" + secret = map[string][]byte{ + key: []byte("123"), + } + expectedResult = int64(0) + }) + It("should return 0 for the result", func() { + result, err := IntOrZero(secret, "invalid-key") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when the secret map is properly populated and a value is selected", func() { + BeforeEach(func() { + key = "key" + secret = map[string][]byte{ + key: []byte("123"), + } + expectedResult = int64(123) + }) + It("should return the correct integer value", func() { + result, err := IntOrZero(secret, key) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when the secret map contains invalid integers", func() { + BeforeEach(func() { + key = "key" + secret = map[string][]byte{ + key: []byte("invalid"), + } + }) + It("an error should be reported", func() { + _, err := IntOrZero(secret, key) + Expect(err).To(HaveOccurred()) + }) + }) + +}) + +var _ = Describe("detectGitAuthType", func() { + var ( + secret map[string][]byte + expectedResult GitAuthenticationBackend + ) + + Context("When a secret containing no authentication details is provided", func() { + BeforeEach(func() { + secret = nil + expectedResult = GitAuthNone + }) + It("should return GitAuthNone", func() { + result := detectGitAuthType(secret) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when a username and password is provided", func() { + BeforeEach(func() { + secret = map[string][]byte{ + "username": []byte("myusername"), + "password": []byte("mypassword"), + } + expectedResult = GitAuthPassword + }) + It("should return GitAuthPassword", func() { + result := detectGitAuthType(secret) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when a SSH private key is provided", func() { + BeforeEach(func() { + secret = map[string][]byte{ + "sshPrivateKey": []byte("ssh-key-data"), + } + expectedResult = GitAuthSsh + }) + It("should return GitAuthSsh", func() { + result := detectGitAuthType(secret) + Expect(result).To(Equal(expectedResult)) + }) + }) + + Context("when a GitHub App is provided", func() { + BeforeEach(func() { + secret = map[string][]byte{ + "githubAppID": []byte("github-app-id"), + "githubAppInstallationID": []byte("github-app-installation-id"), + "githubAppPrivateKey": []byte("github-app-private-key"), + } + expectedResult = GitAuthGitHubApp + }) + It("should return GitAuthGitHubApp", func() { + result := detectGitAuthType(secret) + Expect(result).To(Equal(expectedResult)) + }) + }) + +}) + +var _ = Describe("getGitHubAppAuthTransport", func() { + var ( + clientset *fake.Clientset + secret map[string][]byte + ) + + BeforeEach(func() { + clientset = fake.NewSimpleClientset() + secret = map[string][]byte{ + "githubAppPrivateKey": []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuuCWq9FEvSUw9fhuQzs8D+fE/GHLTruKVxlWwECKhju/5yrf +Eougt6DGQXDDpPY8PtW9aDd45/8iIJJuCPrTU8poDN52qhI0VQPYqyMDcHJZQcXX +pfyCrwTrJf5o++l9sOP6IVZBIZTGtiKOPJNE04K/5UebCQ7mwDgPOdT7tXxmFzcO +1PW+uSq8XRF8PfvMDVAub04X9sm8TNGhkd5yoR9fXFRNjRvzBgc0uikvIVv7HyY2 +HF1sk1MyC9qW0WpiiRPFoEnLh2m+qCnmlZUzeP8fmKZsCLmJazzFcjhX+ExU80fd +sNxpMfRO+hDenVFTOuJpSa6h88LY7GPBBsy8DQIDAQABAoIBAQC3Mi/CY7XVDj5/ +AnllIw5wMS7kkyHxHtwxIj/u29ZwXOZ1QYvI7GQzX0K7KEZC0rigiHvTTH4UQAI+ +mA2SdADy5Ts3UmZVtt7icJDYw8w9UXu6hK4wo+egl1vFtS9JtM1ouTSdtabHusdK +CXoSW/RevJBNvfJ34MnIqawTb30JnSrIWpnwASx4jfjj8vT+jcAGiNDCIptwQN1a +YHGsfmKt3OW0avu9r8Y5W+dgZLDXtEy7/jrVQRNiCNMZBc3WFNWcHzAOJbQfax3v +EOGkypI0lqD7CDUo7bFDL/D5FtCvKC8IgPfQ84x6kUObHcxHnQZra6VZ3I0IlMMg +adBrvIlBAoGBANtXFe8xzPvGCYwpwMuI7FSafKL7vGAh/OQDjgL/hATxEghMTggD +bLFHgp56UojaRCq3HtuBXzocUPyXdk+CiqX/Zj4+5O8fFGlS5PRVOhafOViYIyE4 +11FZvR3rgFWq5TQ5tQMtRUHeq6WbMMnfZjciOGGt3kG5k2jnqrQU6jaVAoGBANoc +g0PjOHWTgus506e0Lkowel7NojFF5Gt5t/zoyexYCDm43FwvIW8uV/SdDEU9uXaG +05TlPGdKFaAqQ9kHSE/O0yrd4GCLDQmt3I1++GqD+JVzJ8uPhQhN7vxfOz3RVQ9+ +DOk/7NGRBgdueUdRXMjg4NscEsfzupuZJglL82mZAoGAG/uTP83hselFBI27G/xe +8jg3WG+3S6hqZAiUEIvaourCey6I8frF3iQaZO+EIhN+iNiN5kEuDfLY3jDQljo4 +SA86UwyhFmSnrPw3W3iYDZTIsyXNrYpb5fQF7ZBC8ir4TN5j2oDnCg1HZrxS0B5h +Iv2JpeSRq17qkIKlw427h7UCgYBmtD5rXTdcxhVDxnsP4Rxa+vDka1gQc6TXpv0o +LkXG8L0O0SmSju7jd6MbIEiC4knOsjY3SqpiyNPeE4jXTUKTsgRljwz06QU+pYvR +ZRR8s5/+X7dBd1dhTbFXTVCMD2JKZUSXIO7Wz79TCIY7OujB/oJjKpj9ZptcYYUz +o3v/IQKBgQCLPnHN78nJsuaRGPL2Ypg8Ku/u4xQPA//ng0cF6d2TIDXLpBKbtUOQ +Gt2KENSCnHhtaj/RnMlG4I2RVTUTkKn5u81cY+XuGbvjVt2MDCcTniRzOiTkHXgO +9lJ+GXjeWhXo+wKlT5YX4s0U8AZIQNQU/Rtrx8vGu9d1SbKiF7Mnlw== +-----END RSA PRIVATE KEY-----`), + } + }) + + Context("When empty values are provided", func() { + BeforeEach(func() { + secret = map[string][]byte{} + }) + + It("a an error should be returned", func() { + transport, err := getGitHubAppAuthTransport(clientset, secret) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to initialize GitHub installation transport")) + Expect(transport).To(BeNil()) + }) + }) + + Context("When the default values are provided", func() { + + It("no errors should be returned", func() { + transport, err := getGitHubAppAuthTransport(clientset, secret) + Expect(err).ToNot(HaveOccurred()) + Expect(transport).ToNot(BeNil()) + }) + It("default github API address should be present", func() { + transport, err := getGitHubAppAuthTransport(clientset, secret) + Expect(err).ToNot(HaveOccurred()) + Expect(transport.BaseURL).To(Equal("https://api.github.com")) + }) + }) + + Context("When a custom GitHub Enterprise Address is provided", func() { + JustBeforeEach(func() { + secret["githubAppEnterpriseBaseUrl"] = []byte("https://github.mycompany.com/api/v3") + }) + + It("custom github API address should be present", func() { + transport, err := getGitHubAppAuthTransport(clientset, secret) + Expect(err).ToNot(HaveOccurred()) + Expect(transport.BaseURL).To(Equal("https://github.mycompany.com/api/v3")) + }) + }) +}) + var _ = Describe("GetCurrentClusterVersion", func() { var ( clusterVersion *configv1.ClusterVersion