From 24ab313738f3a8537bfc6725bdbdce422eb3fb5d Mon Sep 17 00:00:00 2001 From: Nimrod Wandera Date: Fri, 1 Sep 2023 15:15:04 -0500 Subject: [PATCH] cogito: add support for Github Enterprise API endpoint Co-authored-by: Nimrod Wandera Co-authored-by: Jin-Chun --- README.md | 4 +++ cmd/cogito/main.go | 11 ++------- cmd/cogito/main_test.go | 12 ++++----- cogito/ghcommitsink.go | 1 + cogito/protocol.go | 14 ++++++++--- cogito/protocol_test.go | 44 +++++++++++++++++++++++++++++++-- cogito/put.go | 4 +-- cogito/put_test.go | 54 +++++++++++++++++++++++++++++++++++++++-- cogito/putter.go | 22 +++++++++++++---- 9 files changed, 135 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1d73a4d2..ff28128e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,10 @@ With reference to the [GitHub Commit status API], the `POST` parameters (`state` The log level (one of `debug`, `info`, `warn`, `error`, `silent`).\ Default: `info`. +- `github_api_endpoint`:\ + Override the default API endpoint. This allows you to post commit statuses to repositories hosted by GitHub Enterprise (GHE) instances. When provided, this parameter must be a valid https url. + Default: `https://api.github.com` + - `log_url`. **DEPRECATED, no-op, will be removed**\ A Google Hangout Chat webhook. Useful to obtain logging for the `check` step for Concourse < v7.x diff --git a/cmd/cogito/main.go b/cmd/cogito/main.go index 55005b3a..972c12ca 100644 --- a/cmd/cogito/main.go +++ b/cmd/cogito/main.go @@ -50,21 +50,14 @@ func mainErr(in io.Reader, out io.Writer, logOut io.Writer, args []string) error }) log.Info(cogito.BuildInfo()) - ghAPI := os.Getenv("COGITO_GITHUB_API") - if ghAPI != "" { - log.Info("endpoint override", "COGITO_GITHUB_API", ghAPI) - } else { - ghAPI = github.API - } - switch cmd { case "check": return cogito.Check(log, input, out, args[1:]) case "in": return cogito.Get(log, input, out, args[1:]) case "out": - putter := cogito.NewPutter(ghAPI, log) - return cogito.Put(log, input, out, args[1:], putter) + putter := cogito.NewPutter(github.API, log) + return cogito.Put(input, out, args[1:], putter) default: return fmt.Errorf("cli wiring error; please report") } diff --git a/cmd/cogito/main_test.go b/cmd/cogito/main_test.go index 5a259560..396d443b 100644 --- a/cmd/cogito/main_test.go +++ b/cmd/cogito/main_test.go @@ -69,11 +69,12 @@ func TestRunPutSuccess(t *testing.T) { googleChatSpy := testhelp.SpyHttpServer(&chatMsg, chatReply, &gchatUrl, http.StatusOK) in := bytes.NewReader(testhelp.ToJSON(t, cogito.PutRequest{ Source: cogito.Source{ - Owner: "the-owner", - Repo: "the-repo", - AccessToken: "the-secret", - GChatWebHook: googleChatSpy.URL, - LogLevel: "debug", + Owner: "the-owner", + Repo: "the-repo", + AccessToken: "the-secret", + GithubApiEndpoint: gitHubSpy.URL, + GChatWebHook: googleChatSpy.URL, + LogLevel: "debug", }, Params: cogito.PutParams{State: wantState}, })) @@ -81,7 +82,6 @@ func TestRunPutSuccess(t *testing.T) { var logOut bytes.Buffer inputDir := testhelp.MakeGitRepoFromTestdata(t, "../../cogito/testdata/one-repo/a-repo", testhelp.HttpsRemote("the-owner", "the-repo"), "dummySHA", wantGitRef) - t.Setenv("COGITO_GITHUB_API", gitHubSpy.URL) err := mainErr(in, &out, &logOut, []string{"out", inputDir}) diff --git a/cogito/ghcommitsink.go b/cogito/ghcommitsink.go index 3d86acf3..1a6cea87 100644 --- a/cogito/ghcommitsink.go +++ b/cogito/ghcommitsink.go @@ -58,6 +58,7 @@ func (sink GitHubCommitStatusSink) Send() error { "state", ghState, "owner", sink.Request.Source.Owner, "repo", sink.Request.Source.Repo, "git-ref", sink.GitRef, "context", context, "buildURL", buildURL, "description", description) + if err := commitStatus.Add(sink.GitRef, ghState, buildURL, description); err != nil { return err } diff --git a/cogito/protocol.go b/cogito/protocol.go index ac0b2d95..48ab969d 100644 --- a/cogito/protocol.go +++ b/cogito/protocol.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "net/url" "os" "strings" @@ -171,6 +172,7 @@ type Source struct { ChatAppendSummary bool `json:"chat_append_summary"` ChatNotifyOnStates []BuildState `json:"chat_notify_on_states"` Sinks []string `json:"sinks"` + GithubApiEndpoint string `json:"github_api_endpoint"` } // String renders Source, redacting the sensitive fields. @@ -186,7 +188,8 @@ func (src Source) String() string { fmt.Fprintf(&bld, "chat_append_summary: %t\n", src.ChatAppendSummary) fmt.Fprintf(&bld, "chat_notify_on_states: %s\n", src.ChatNotifyOnStates) // Last one: no newline. - fmt.Fprintf(&bld, "sinks: %s", src.Sinks) + fmt.Fprintf(&bld, "sinks: %s\n", src.Sinks) + fmt.Fprintf(&bld, "github_api_endpoint: %s", src.GithubApiEndpoint) return bld.String() } @@ -254,10 +257,13 @@ func (src *Source) Validate() error { return fmt.Errorf("source: missing keys: %s", strings.Join(mandatory, ", ")) } - // // Validate optional fields. - // - // In this case, nothing to validate. + if src.GithubApiEndpoint != "" { + u, err := url.ParseRequestURI(src.GithubApiEndpoint) + if err != nil || u.Host == "" { + return fmt.Errorf("source: github_api_endpoint '%s' is an invalid api endpoint", src.GithubApiEndpoint) + } + } // // Apply defaults. diff --git a/cogito/protocol_test.go b/cogito/protocol_test.go index 508910ac..304b62ff 100644 --- a/cogito/protocol_test.go +++ b/cogito/protocol_test.go @@ -46,6 +46,14 @@ func TestSourceValidationSuccess(t *testing.T) { return source }, }, + { + name: "optional git source github_api_endpoint", + mkSource: func() cogito.Source { + source := baseGithubSource + source.GithubApiEndpoint = "https://github.coffee.com/api/v3" + return source + }, + }, } for _, tc := range testCases { @@ -102,6 +110,36 @@ func TestSourceValidationFailure(t *testing.T) { }, wantErr: "source: invalid sink(s): [closed coffee shop]", }, + { + name: "no protocol prefix in git source github_api_endpoint", + source: cogito.Source{ + Owner: "the-owner", + Repo: "the-repo", + AccessToken: "the-token", + GithubApiEndpoint: "github.coffee.com/api/v3", + }, + wantErr: "source: github_api_endpoint 'github.coffee.com/api/v3' is an invalid api endpoint", + }, + { + name: "invalid http protocol prefix in git source github_api_endpoint", + source: cogito.Source{ + Owner: "the-owner", + Repo: "the-repo", + AccessToken: "the-token", + GithubApiEndpoint: "https:github.coffee.com/api/v3", + }, + wantErr: "source: github_api_endpoint 'https:github.coffee.com/api/v3' is an invalid api endpoint", + }, + { + name: "invalid http protocol prefix in git source github_api_endpoint", + source: cogito.Source{ + Owner: "the-owner", + Repo: "the-repo", + AccessToken: "the-token", + GithubApiEndpoint: "john.smith.cim", + }, + wantErr: "source: github_api_endpoint 'john.smith.cim' is an invalid api endpoint", + }, } for _, tc := range testCases { @@ -192,7 +230,8 @@ log_level: debug context_prefix: the-prefix chat_append_summary: true chat_notify_on_states: [success failure] -sinks: []` +sinks: [] +github_api_endpoint: ` have := fmt.Sprint(source) @@ -211,7 +250,8 @@ log_level: context_prefix: chat_append_summary: false chat_notify_on_states: [] -sinks: []` +sinks: [] +github_api_endpoint: ` have := fmt.Sprint(input) diff --git a/cogito/put.go b/cogito/put.go index 6ca058c0..5060ca72 100644 --- a/cogito/put.go +++ b/cogito/put.go @@ -4,8 +4,6 @@ import ( "fmt" "io" "strings" - - "github.com/hashicorp/go-hclog" ) // Putter represents the put step of a Concourse resource. @@ -40,7 +38,7 @@ type Sinker interface { // Additionally, the script may emit metadata as a list of key-value pairs. This data is // intended for public consumption and will make it upstream, intended to be shown on the // build's page. -func Put(log hclog.Logger, input []byte, out io.Writer, args []string, putter Putter) error { +func Put(input []byte, out io.Writer, args []string, putter Putter) error { if err := putter.LoadConfiguration(input, args); err != nil { return fmt.Errorf("put: %s", err) } diff --git a/cogito/put_test.go b/cogito/put_test.go index 23508b9d..dd222d8b 100644 --- a/cogito/put_test.go +++ b/cogito/put_test.go @@ -3,6 +3,7 @@ package cogito_test import ( "errors" "fmt" + "github.com/Pix4D/cogito/github" "io" "path/filepath" "testing" @@ -64,7 +65,7 @@ func (ms MockSinker) Send() error { func TestPutSuccess(t *testing.T) { putter := MockPutter{sinkers: []cogito.Sinker{MockSinker{}}} - err := cogito.Put(hclog.NewNullLogger(), nil, nil, nil, putter) + err := cogito.Put(nil, nil, nil, putter) assert.NilError(t, err) } @@ -77,7 +78,7 @@ func TestPutFailure(t *testing.T) { } test := func(t *testing.T, tc testCase) { - err := cogito.Put(hclog.NewNullLogger(), nil, nil, nil, tc.putter) + err := cogito.Put(nil, nil, nil, tc.putter) assert.ErrorContains(t, err, tc.wantErr) } @@ -153,6 +154,22 @@ func TestPutterLoadConfigurationSinksOverrideSuccess(t *testing.T) { assert.NilError(t, err) } +func TestPutterLoadConfigurationGhApiEndpointOverrideSuccess(t *testing.T) { + in := []byte(` + { + "source": { + "owner": "the-owner", + "repo": "the-repo", + "access_token": "the-token", + "github_api_endpoint": "https://ghe.company.com" + } + }`) + putter := cogito.NewPutter("dummy-API", hclog.NewNullLogger()) + inputDir := []string{""} + err := putter.LoadConfiguration(in, inputDir) + assert.NilError(t, err) +} + func TestPutterLoadConfigurationFailure(t *testing.T) { type testCase struct { name string @@ -190,6 +207,20 @@ func TestPutterLoadConfigurationFailure(t *testing.T) { args: []string{}, wantErr: "put: concourse resource protocol violation: missing input directory", }, + { + name: "invalid GH endpoint url in source", + putInput: cogito.PutRequest{ + Source: cogito.Source{ + Owner: "owner", + Repo: "repo", + AccessToken: "token", + GithubApiEndpoint: "invalid-api-endpoint", + }, + Params: cogito.PutParams{State: cogito.StateSuccess}, + }, + args: []string{}, + wantErr: "put: source: github_api_endpoint 'invalid-api-endpoint' is an invalid api endpoint", + }, } for _, tc := range testCases { @@ -405,6 +436,25 @@ func TestPutterSinks(t *testing.T) { assert.Assert(t, ok2) } +func TestGitHubCommitStatusSinkApiEndpointOverrideFromSource(t *testing.T) { + // default case + defaultApiEndpoint := github.API + defaultPutter := cogito.NewPutter(defaultApiEndpoint, hclog.NewNullLogger()) + sinks := defaultPutter.Sinks() + ghSink := sinks[1].(cogito.GitHubCommitStatusSink) + assert.Assert(t, ghSink.GhAPI == defaultApiEndpoint) + + // override + customEndpointPutter := cogito.NewPutter(defaultApiEndpoint, hclog.NewNullLogger()) + customEndpoint := "https://ghe.company.com" + customEndpointPutter.Request = cogito.PutRequest{ + Source: cogito.Source{GithubApiEndpoint: customEndpoint}, + } + customPutterSinks := customEndpointPutter.Sinks() + customPutterGhSink := customPutterSinks[1].(cogito.GitHubCommitStatusSink) + assert.Assert(t, customPutterGhSink.GhAPI == customEndpoint) +} + func TestPutterCustomSinks(t *testing.T) { putter := cogito.NewPutter("dummy-api", hclog.NewNullLogger()) putter.Request = cogito.PutRequest{ diff --git a/cogito/putter.go b/cogito/putter.go index ad9ef048..b6adb7e0 100644 --- a/cogito/putter.go +++ b/cogito/putter.go @@ -152,10 +152,12 @@ func (putter *ProdPutter) ProcessInputDir() error { } func (putter *ProdPutter) Sinks() []Sinker { + source := putter.Request.Source + ghApiEndpoint := getEndpointFromSourceOrDefault(putter, source) supportedSinkers := map[string]Sinker{ "github": GitHubCommitStatusSink{ Log: putter.log.Named("ghCommitStatus"), - GhAPI: putter.ghAPI, + GhAPI: ghApiEndpoint, GitRef: putter.gitRef, Request: putter.Request, }, @@ -167,9 +169,9 @@ func (putter *ProdPutter) Sinks() []Sinker { Request: putter.Request, }, } - source := putter.Request.Source.Sinks + sourceSinks := source.Sinks params := putter.Request.Params.Sinks - sinks, _ := MergeAndValidateSinks(source, params) + sinks, _ := MergeAndValidateSinks(sourceSinks, params) sinkers := make([]Sinker, 0, sinks.Size()) for _, s := range sinks.OrderedList() { @@ -179,6 +181,15 @@ func (putter *ProdPutter) Sinks() []Sinker { return sinkers } +func getEndpointFromSourceOrDefault(putter *ProdPutter, source Source) string { + if source.GithubApiEndpoint != "" { + return source.GithubApiEndpoint + } else { + // the default + return putter.ghAPI + } +} + func (putter *ProdPutter) Output(out io.Writer) error { // Following the protocol for put, we return the version and metadata. // For Cogito, the metadata contains the Concourse build state. @@ -262,8 +273,9 @@ func checkGitRepoDir(dir, owner, repo string) error { if err != nil { return fmt.Errorf(".git/config: remote: %w", err) } - left := []string{"github.com", owner, repo} - right := []string{gu.URL.Host, gu.Owner, gu.Repo} + + left := []string{owner, repo} + right := []string{gu.Owner, gu.Repo} for i, l := range left { r := right[i] if !strings.EqualFold(l, r) {