diff --git a/Dockerfile b/Dockerfile index 96035cba..53973a37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,10 @@ RUN apk add --update --no-cache \ COPY scripts/askpass.sh /usr/local/bin/askpass.sh COPY --from=builder /go/src/github.com/telia-oss/github-pr-resource/build /opt/resource RUN chmod +x /opt/resource/* - +ENV GITHUB_APP_CRED_HELPER_VERSION="v0.3.3" +ENV BIN_PATH_TARGET=/usr/local/bin +RUN curl -L https://github.com/bdellegrazie/git-credential-github-app/releases/download/${GITHUB_APP_CRED_HELPER_VERSION}/git-credential-github-app_${GITHUB_APP_CRED_HELPER_VERSION}_Linux_x86_64.tar.gz | tar zxv -C ${BIN_PATH_TARGET} +COPY --from=builder /go/src/github.com/telia-oss/github-pr-resource/build /opt/resource FROM resource diff --git a/README.md b/README.md index 461d6fcb..e8f7074c 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,16 @@ automated tests. | `required_review_approvals` | No | `2` | Disable triggering of the resource if the pull request does not have at least `X` approved review(s). | | `trusted_teams` | No | `["wg-cf-on-k8s-bots"]` | PRs from members of the trusted teams always trigger the resource regardless of the PR approval status. | | `trusted_users` | No | `["dependabot"]` | PRs from trusted users always trigger the resource regardless of the PR approval status. | -| `git_crypt_key` | No | `AEdJVENSWVBUS0VZAAAAA...` | Base64 encoded git-crypt key. Setting this will unlock / decrypt the repository with git-crypt. To get the key simply execute `git-crypt export-key -- - | base64` in an encrypted repository. | +| `git_crypt_key` | No | `AEdJVENSWVBUS0VZAAAAA...` | Base64 encoded git-crypt key. Setting this will unlock / decrypt the repository with git-crypt. To get the key simply execute `git-crypt export-key -- - | base64` in an encrypted repository. | | `base_branch` | No | `master` | Name of a branch. The pipeline will only trigger on pull requests against the specified branch. | | `labels` | No | `["bug", "enhancement"]` | The labels on the PR. The pipeline will only trigger on pull requests having at least one of the specified labels. | | `disable_git_lfs` | No | `true` | Disable Git LFS, skipping an attempt to convert pointers of files tracked into their corresponding objects when checked out into a working copy. | | `states` | No | `["OPEN", "MERGED"]` | The PR states to select (`OPEN`, `MERGED` or `CLOSED`). The pipeline will only trigger on pull requests matching one of the specified states. Default is ["OPEN"]. | +| `use_github_app` | No | `false` | Whether to authenticate using a github app or not. | +| `github_organization` | No | `Vault-tec` | Which Github organization your github app is in. | +| `private_key` | No | `-----BEGIN RSA...` | Private key for your github app. | +| `installation_id` | No | `12356` | Installation id for your github app. | +| `application_id` | No | `12356` | Application id for your github app. | **Notes:** diff --git a/git.go b/git.go index c4b45208..290b757a 100644 --- a/git.go +++ b/git.go @@ -36,17 +36,25 @@ func NewGitClient(source *Source, dir string, output io.Writer) (*GitClient, err os.Setenv("GIT_LFS_SKIP_SMUDGE", "true") } return &GitClient{ - AccessToken: source.AccessToken, - Directory: dir, - Output: output, + AccessToken: source.AccessToken, + PrivateKey: source.PrivateKey, + UseGithubApp: source.UseGitHubApp, + ApplicationID: source.ApplicationID, + GithubOrganization: source.GithubOrganization, + Directory: dir, + Output: output, }, nil } // GitClient ... type GitClient struct { - AccessToken string - Directory string - Output io.Writer + AccessToken string + UseGithubApp bool + Directory string + ApplicationID int64 + GithubOrganization string + PrivateKey string + Output io.Writer } func (g *GitClient) command(name string, arg ...string) *exec.Cmd { @@ -55,9 +63,15 @@ func (g *GitClient) command(name string, arg ...string) *exec.Cmd { cmd.Stdout = g.Output cmd.Stderr = g.Output cmd.Env = os.Environ() + if !g.UseGithubApp { + cmd.Env = append(cmd.Env, + "X_OAUTH_BASIC_TOKEN="+g.AccessToken) + } + cmd.Env = append(cmd.Env, - "X_OAUTH_BASIC_TOKEN="+g.AccessToken, "GIT_ASKPASS=/usr/local/bin/askpass.sh") + fmt.Fprint(os.Stderr, fmt.Sprintf("\n%s %v", name, arg)) + return cmd } @@ -75,12 +89,29 @@ func (g *GitClient) Init(branch string) error { if err := g.command("git", "config", "--global", "user.email", "concourse@local").Run(); err != nil { return fmt.Errorf("failed to configure git email: %s", err) } - if err := g.command("git", "config", "--global", "url.https://x-oauth-basic@github.com/.insteadOf", "git@github.com:").Run(); err != nil { - return fmt.Errorf("failed to configure github url: %s", err) - } if err := g.command("git", "config", "--global", "url.https://.insteadOf", "git://").Run(); err != nil { return fmt.Errorf("failed to configure github url: %s", err) } + if !g.UseGithubApp { + if err := g.command("git", "config", "url.https://x-oauth-basic@github.com/.insteadOf", "git@github.com:").Run(); err != nil { + return fmt.Errorf("failed to configure github url: %s", err) + } + } else { + err := ioutil.WriteFile(filePath, []byte(g.PrivateKey), 0600) + if err != nil { + fmt.Println("Error writing private key:", err) + os.Exit(1) + } + + helperStr := fmt.Sprintf("!git-credential-github-app --appId %d -organization %s -username x-access-token -privateKeyFile /tmp/git-resource-private-key", g.ApplicationID, g.GithubOrganization) + if err := g.command("git", "config", "credential.https://github.com.helper", helperStr).Run(); err != nil { + return fmt.Errorf("failed to configure github url: %s", err) + } + } else { + if err := g.command("git", "config", "url.https://x-oauth-basic@github.com/.insteadOf", "git@github.com:").Run(); err != nil { + return fmt.Errorf("failed to configure github url: %s", err) + } + } return nil } @@ -232,6 +263,9 @@ func (g *GitClient) Endpoint(uri string) (string, error) { if err != nil { return "", fmt.Errorf("failed to parse commit url: %s", err) } - endpoint.User = url.UserPassword("x-oauth-basic", g.AccessToken) + if !g.UseGithubApp { + endpoint.User = url.UserPassword("x-oauth-basic", g.AccessToken) + } + return endpoint.String(), nil } diff --git a/github.go b/github.go index c1410aa9..d68337cb 100644 --- a/github.go +++ b/github.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v61/github" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" @@ -46,23 +47,38 @@ func NewGithubClient(s *Source) (*GithubClient, error) { return nil, err } + // We need a transport that we can update if using GitHub App authentication + transport := http.DefaultTransport.(*http.Transport).Clone() + // Skip SSL verification for self-signed certificates // source: https://github.com/google/go-github/pull/598#issuecomment-333039238 var ctx context.Context if s.SkipSSLVerification { - insecureClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + insecureClient := &http.Client{Transport: transport} ctx = context.WithValue(context.TODO(), oauth2.HTTPClient, insecureClient) } else { ctx = context.TODO() } - client := oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: s.AccessToken}, - )) + var client *http.Client + if !s.UseGitHubApp { + // Client using oauth2 wrapper + client = oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: s.AccessToken}, + )) + } else { + var ghAppInstallationTransport *ghinstallation.Transport + ghAppInstallationTransport, err = ghinstallation.New(transport, s.ApplicationID, s.InstallationID, []byte(s.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("failed to generate application installation access token using private key: %s", err) + } + + // Client using ghinstallation transport + client = &http.Client{ + Transport: ghAppInstallationTransport, + } + } var v3 *github.Client if s.V3Endpoint != "" { diff --git a/go.mod b/go.mod index d0f34145..ed26db26 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.23.7 require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 github.com/google/go-github/v61 v61.0.0 github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 @@ -14,6 +15,8 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + github.com/google/go-github/v69 v69.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index cef66eec..ba6c0066 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ +github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 h1:0D4vKCHOvYrDU8u61TnE2JfNT4VRrBLphmxtqazTO+M= +github.com/bradleyfalzon/ghinstallation/v2 v2.14.0/go.mod h1:LOVmdZYVZ8jqdr4n9wWm1ocDiMz9IfMGfRkaYC1a52A= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= +github.com/google/go-github/v69 v69.0.0 h1:YnFvZ3pEIZF8KHmI8xyQQe3mYACdkhnaTV2hr7CP2/w= +github.com/google/go-github/v69 v69.0.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/models.go b/models.go index 6695308f..1143d761 100644 --- a/models.go +++ b/models.go @@ -29,12 +29,31 @@ type Source struct { States []githubv4.PullRequestState `json:"states"` TrustedTeams []string `json:"trusted_teams"` TrustedUsers []string `json:"trusted_users"` + UseGitHubApp bool `json:"use_github_app"` + GithubOrganization string `json:"github_organization"` + PrivateKey string `json:"private_key"` + ApplicationID int64 `json:"application_id"` + InstallationID int64 `json:"installation_id"` } // Validate the source configuration. func (s *Source) Validate() error { - if s.AccessToken == "" { - return errors.New("access_token must be set") + if s.AccessToken == "" && !s.UseGitHubApp { + return errors.New("access_token must be set if not using GitHub App authentication") + } + if s.UseGitHubApp { + if s.PrivateKey == "" { + return errors.New("private_key is required for GitHub App authentication") + } + if s.ApplicationID == 0 || s.InstallationID == 0 { + return errors.New("application_id and installation_id must be set if using GitHub App authentication") + } + if len(s.GithubOrganization) == 0 { + return errors.New("github_organization must be set if using GitHub App authentication") + } + if s.AccessToken != "" { + return errors.New("access_token is not required when using GitHub App authentication") + } } if s.Repository == "" { return errors.New("repository must be set") diff --git a/models_test.go b/models_test.go new file mode 100644 index 00000000..faebb1b0 --- /dev/null +++ b/models_test.go @@ -0,0 +1,120 @@ +package resource_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + resource "github.com/telia-oss/github-pr-resource" +) + +func TestSource(t *testing.T) { + tests := []struct { + description string + source resource.Source + wantErr string + }{ + { + description: "validate passes", + source: resource.Source{ + AccessToken: "123456", + Repository: "test/test", + }, + }, + { + description: "should have an access_token", + source: resource.Source{ + Repository: "test/test", + }, + wantErr: "access_token must be set if not using GitHub App authentication", + }, + { + description: "should have a repository", + source: resource.Source{ + AccessToken: "123456", + }, + wantErr: "repository must be set", + }, + { + description: "should support GitHub App authentication", + source: resource.Source{ + Repository: "test/test", + GithubOrganization: "test", + UseGitHubApp: true, + PrivateKey: "key.pem", + ApplicationID: 123456, + InstallationID: 1, + }, + }, + { + description: "private_key App configuration values", + source: resource.Source{ + Repository: "test/test", + UseGitHubApp: true, + ApplicationID: 123456, + InstallationID: 1, + }, + wantErr: "private_key is required for GitHub App authentication", + }, + { + description: "requires an application_id and installation_id GitHub App configuration values", + source: resource.Source{ + Repository: "test/test", + UseGitHubApp: true, + PrivateKey: "key.pem", + ApplicationID: 123456, + }, + wantErr: "application_id and installation_id must be set if using GitHub App authentication", + }, + { + description: "should not have an access_token when using GitHub App authentication", + source: resource.Source{ + Repository: "test/test", + UseGitHubApp: true, + GithubOrganization: "test", + PrivateKey: "key.pem", + ApplicationID: 123456, + InstallationID: 1, + AccessToken: "123456", + }, + wantErr: "access_token is not required when using GitHub App authentication", + }, + { + description: "requires v3_endpoint when v4_endpoint is set", + source: resource.Source{ + AccessToken: "123456", + Repository: "test/test", + V3Endpoint: "https://github.com/v3", + }, + wantErr: "v4_endpoint must be set together with v3_endpoint", + }, + { + description: "requires v4_endpoint when v3_endpoint is set", + source: resource.Source{ + AccessToken: "123456", + Repository: "test/test", + V4Endpoint: "https://github.com/v4", + }, + wantErr: "v3_endpoint must be set together with v4_endpoint", + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + err := tc.source.Validate() + + if tc.wantErr != "" { + if err == nil { + t.Logf("Expected error '%s', got nothing", tc.wantErr) + t.Fail() + } + assert.EqualError(t, err, tc.wantErr, fmt.Sprintf("Expected '%s', got '%s'", tc.wantErr, err)) + } + + if tc.wantErr == "" && err != nil { + t.Logf("Got an error when none expected: %s", err) + t.Fail() + } + }) + } +}