1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2024-12-12 10:55:20 +02:00

feat(orchestrator): add implementation for GitHub (#4525)

* add comments with examples to methods

* a bit refactoring and cleanup

* actionsURL

* GetBuildStatus

* GetBuildID, GetChangeSet, GetPipelineStartTime

* GetStageName and GetBuildReason

* refactor fetching jobs

* GetJobName and GetJobURL

* chnage GetBuildURL

* refactor actionsURL

* fix guessCurrentJob bug

* unit tests for all

* refactor GetLog

* refactor and fix tests

* change GetBuildURL to use env vars

* fix issues

* leftover

* add comment

* fix according to review comments

---------

Co-authored-by: Gulom Alimov <gulomjon.alimov@sap.com>
Co-authored-by: Jordi van Liempt <35920075+jliempt@users.noreply.github.com>
This commit is contained in:
Googlom 2023-08-29 12:32:35 +05:00 committed by GitHub
parent b7663466f3
commit e805beda70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 531 additions and 296 deletions

View File

@ -13,20 +13,17 @@ import (
type AzureDevOpsConfigProvider struct { type AzureDevOpsConfigProvider struct {
client piperHttp.Client client piperHttp.Client
options piperHttp.ClientOptions
apiInformation map[string]interface{} apiInformation map[string]interface{}
} }
// InitOrchestratorProvider initializes http client for AzureDevopsConfigProvider // InitOrchestratorProvider initializes http client for AzureDevopsConfigProvider
func (a *AzureDevOpsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) { func (a *AzureDevOpsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) {
a.client = piperHttp.Client{} a.client.SetOptions(piperHttp.ClientOptions{
a.options = piperHttp.ClientOptions{
Username: "", Username: "",
Password: settings.AzureToken, Password: settings.AzureToken,
MaxRetries: 3, MaxRetries: 3,
TransportTimeout: time.Second * 10, TransportTimeout: time.Second * 10,
} })
a.client.SetOptions(a.options)
log.Entry().Debug("Successfully initialized Azure config provider") log.Entry().Debug("Successfully initialized Azure config provider")
} }
@ -102,12 +99,12 @@ func (a *AzureDevOpsConfigProvider) GetBuildStatus() string {
// cases to align with Jenkins: SUCCESS, FAILURE, NOT_BUILD, ABORTED // cases to align with Jenkins: SUCCESS, FAILURE, NOT_BUILD, ABORTED
switch buildStatus := getEnv("AGENT_JOBSTATUS", "FAILURE"); buildStatus { switch buildStatus := getEnv("AGENT_JOBSTATUS", "FAILURE"); buildStatus {
case "Succeeded": case "Succeeded":
return "SUCCESS" return BuildStatusSuccess
case "Canceled": case "Canceled":
return "ABORTED" return BuildStatusAborted
default: default:
// Failed, SucceededWithIssues // Failed, SucceededWithIssues
return "FAILURE" return BuildStatusFailure
} }
} }

View File

@ -2,7 +2,6 @@ package orchestrator
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -14,53 +13,51 @@ import (
"github.com/SAP/jenkins-library/pkg/log" "github.com/SAP/jenkins-library/pkg/log"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
) )
var httpHeader = http.Header{ type GitHubActionsConfigProvider struct {
"Accept": {"application/vnd.github+json"}, client piperHttp.Client
runData run
jobs []job
jobsFetched bool
currentJob job
} }
type GitHubActionsConfigProvider struct { type run struct {
client piperHttp.Client fetched bool
Status string `json:"status"`
StartedAt time.Time `json:"run_started_at"`
} }
type job struct { type job struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"`
HtmlURL string `json:"html_url"`
} }
type stagesID struct { type fullLog struct {
Jobs []job `json:"jobs"`
}
type logs struct {
sync.Mutex sync.Mutex
b [][]byte b [][]byte
} }
var httpHeaders = http.Header{
"Accept": {"application/vnd.github+json"},
"X-GitHub-Api-Version": {"2022-11-28"},
}
// InitOrchestratorProvider initializes http client for GitHubActionsDevopsConfigProvider // InitOrchestratorProvider initializes http client for GitHubActionsDevopsConfigProvider
func (g *GitHubActionsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) { func (g *GitHubActionsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) {
g.client = piperHttp.Client{}
g.client.SetOptions(piperHttp.ClientOptions{ g.client.SetOptions(piperHttp.ClientOptions{
Password: settings.GitHubToken, Token: "Bearer " + settings.GitHubToken,
MaxRetries: 3, MaxRetries: 3,
TransportTimeout: time.Second * 10, TransportTimeout: time.Second * 10,
}) })
log.Entry().Debug("Successfully initialized GitHubActions config provider") log.Entry().Debug("Successfully initialized GitHubActions config provider")
} }
func getActionsURL() string {
ghURL := getEnv("GITHUB_URL", "")
switch ghURL {
case "https://github.com/":
ghURL = "https://api.github.com"
default:
ghURL += "api/v3"
}
return fmt.Sprintf("%s/repos/%s/actions", ghURL, getEnv("GITHUB_REPOSITORY", ""))
}
func (g *GitHubActionsConfigProvider) OrchestratorVersion() string { func (g *GitHubActionsConfigProvider) OrchestratorVersion() string {
log.Entry().Debugf("OrchestratorVersion() for GitHub Actions is not applicable.")
return "n/a" return "n/a"
} }
@ -68,109 +65,141 @@ func (g *GitHubActionsConfigProvider) OrchestratorType() string {
return "GitHubActions" return "GitHubActions"
} }
// GetBuildStatus returns current run status
func (g *GitHubActionsConfigProvider) GetBuildStatus() string { func (g *GitHubActionsConfigProvider) GetBuildStatus() string {
log.Entry().Infof("GetBuildStatus() for GitHub Actions not yet implemented.") g.fetchRunData()
return "FAILURE" switch g.runData.Status {
case "success":
return BuildStatusSuccess
case "cancelled":
return BuildStatusAborted
case "in_progress":
return BuildStatusInProgress
default:
return BuildStatusFailure
}
} }
// GetLog returns the whole logfile for the current pipeline run // GetLog returns the whole logfile for the current pipeline run
func (g *GitHubActionsConfigProvider) GetLog() ([]byte, error) { func (g *GitHubActionsConfigProvider) GetLog() ([]byte, error) {
ids, err := g.getStageIds() if err := g.fetchJobs(); err != nil {
if err != nil {
return nil, err return nil, err
} }
// Ignore the last stage (job) as it is not possible in GitHub to fetch logs for a running job.
jobs := g.jobs[:len(g.jobs)-1]
logs := logs{ fullLogs := fullLog{b: make([][]byte, len(jobs))}
b: make([][]byte, len(ids)),
}
ctx := context.Background()
sem := semaphore.NewWeighted(10)
wg := errgroup.Group{} wg := errgroup.Group{}
for i := range ids { wg.SetLimit(10)
for i := range jobs {
i := i // https://golang.org/doc/faq#closures_and_goroutines i := i // https://golang.org/doc/faq#closures_and_goroutines
if err := sem.Acquire(ctx, 1); err != nil {
return nil, fmt.Errorf("failed to acquire semaphore: %w", err)
}
wg.Go(func() error { wg.Go(func() error {
defer sem.Release(1) resp, err := g.client.GetRequest(fmt.Sprintf("%s/jobs/%d/logs", actionsURL(), jobs[i].ID), httpHeaders, nil)
resp, err := g.client.GetRequest(fmt.Sprintf("%s/jobs/%d/logs", getActionsURL(), ids[i]), httpHeader, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to get API data: %w", err) return fmt.Errorf("failed to get API data: %w", err)
} }
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to read response body: %w", err) return fmt.Errorf("failed to read response body: %w", err)
} }
defer resp.Body.Close()
logs.Lock() fullLogs.Lock()
defer logs.Unlock() fullLogs.b[i] = b
logs.b[i] = b fullLogs.Unlock()
return nil return nil
}) })
} }
if err = wg.Wait(); err != nil { if err := wg.Wait(); err != nil {
return nil, fmt.Errorf("failed to get logs: %w", err) return nil, fmt.Errorf("failed to get logs: %w", err)
} }
return bytes.Join(logs.b, []byte("")), nil return bytes.Join(fullLogs.b, []byte("")), nil
} }
// GetBuildID returns current run ID
func (g *GitHubActionsConfigProvider) GetBuildID() string { func (g *GitHubActionsConfigProvider) GetBuildID() string {
log.Entry().Infof("GetBuildID() for GitHub Actions not yet implemented.") return getEnv("GITHUB_RUN_ID", "n/a")
return "n/a"
} }
func (g *GitHubActionsConfigProvider) GetChangeSet() []ChangeSet { func (g *GitHubActionsConfigProvider) GetChangeSet() []ChangeSet {
log.Entry().Warn("GetChangeSet for GitHubActions not yet implemented") log.Entry().Debug("GetChangeSet for GitHubActions not implemented")
return []ChangeSet{} return []ChangeSet{}
} }
// GetPipelineStartTime returns the pipeline start time in UTC
func (g *GitHubActionsConfigProvider) GetPipelineStartTime() time.Time { func (g *GitHubActionsConfigProvider) GetPipelineStartTime() time.Time {
log.Entry().Infof("GetPipelineStartTime() for GitHub Actions not yet implemented.") g.fetchRunData()
return time.Time{}.UTC() return g.runData.StartedAt.UTC()
} }
// GetStageName returns the human-readable name given to a stage.
func (g *GitHubActionsConfigProvider) GetStageName() string { func (g *GitHubActionsConfigProvider) GetStageName() string {
return "GITHUB_WORKFLOW" // TODO: is there something like is "stage" in GH Actions? return getEnv("GITHUB_JOB", "unknown")
} }
// GetBuildReason returns the reason of workflow trigger.
// BuildReasons are unified with AzureDevOps build reasons, see
// https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services
func (g *GitHubActionsConfigProvider) GetBuildReason() string { func (g *GitHubActionsConfigProvider) GetBuildReason() string {
log.Entry().Infof("GetBuildReason() for GitHub Actions not yet implemented.") switch getEnv("GITHUB_EVENT_NAME", "") {
return "n/a" case "workflow_dispatch":
return BuildReasonManual
case "schedule":
return BuildReasonSchedule
case "pull_request":
return BuildReasonPullRequest
case "workflow_call":
return BuildReasonResourceTrigger
case "push":
return BuildReasonIndividualCI
default:
return BuildReasonUnknown
}
} }
// GetBranch returns the source branch name, e.g. main
func (g *GitHubActionsConfigProvider) GetBranch() string { func (g *GitHubActionsConfigProvider) GetBranch() string {
return strings.TrimPrefix(getEnv("GITHUB_REF", "n/a"), "refs/heads/") return getEnv("GITHUB_REF_NAME", "n/a")
} }
// GetReference return the git reference. For example, refs/heads/your_branch_name
func (g *GitHubActionsConfigProvider) GetReference() string { func (g *GitHubActionsConfigProvider) GetReference() string {
return getEnv("GITHUB_REF", "n/a") return getEnv("GITHUB_REF", "n/a")
} }
// GetBuildURL returns the builds URL. For example, https://github.com/SAP/jenkins-library/actions/runs/5815297487
func (g *GitHubActionsConfigProvider) GetBuildURL() string { func (g *GitHubActionsConfigProvider) GetBuildURL() string {
return g.GetRepoURL() + "/actions/runs/" + getEnv("GITHUB_RUN_ID", "n/a") return g.GetRepoURL() + "/actions/runs/" + g.GetBuildID()
} }
// GetJobURL returns the current job HTML URL (not API URL).
// For example, https://github.com/SAP/jenkins-library/actions/runs/123456/jobs/7654321
func (g *GitHubActionsConfigProvider) GetJobURL() string { func (g *GitHubActionsConfigProvider) GetJobURL() string {
log.Entry().Debugf("Not yet implemented.") // We need to query the GitHub API here because the environment variable GITHUB_JOB returns
return g.GetRepoURL() + "/actions/runs/" + getEnv("GITHUB_RUN_ID", "n/a") // the name of the job, not a numeric ID (which we need to form the URL)
g.guessCurrentJob()
return g.currentJob.HtmlURL
} }
// GetJobName returns the current workflow name. For example, "Piper workflow"
func (g *GitHubActionsConfigProvider) GetJobName() string { func (g *GitHubActionsConfigProvider) GetJobName() string {
log.Entry().Debugf("GetJobName() for GitHubActions not yet implemented.") return getEnv("GITHUB_WORKFLOW", "unknown")
return "n/a"
} }
// GetCommit returns the commit SHA that triggered the workflow. For example, ffac537e6cbbf934b08745a378932722df287a53
func (g *GitHubActionsConfigProvider) GetCommit() string { func (g *GitHubActionsConfigProvider) GetCommit() string {
return getEnv("GITHUB_SHA", "n/a") return getEnv("GITHUB_SHA", "n/a")
} }
// GetRepoURL returns full url to repository. For example, https://github.com/SAP/jenkins-library
func (g *GitHubActionsConfigProvider) GetRepoURL() string { func (g *GitHubActionsConfigProvider) GetRepoURL() string {
return getEnv("GITHUB_SERVER_URL", "n/a") + "/" + getEnv("GITHUB_REPOSITORY", "n/a") return getEnv("GITHUB_SERVER_URL", "n/a") + "/" + getEnv("GITHUB_REPOSITORY", "n/a")
} }
// GetPullRequestConfig returns pull request configuration
func (g *GitHubActionsConfigProvider) GetPullRequestConfig() PullRequestConfig { func (g *GitHubActionsConfigProvider) GetPullRequestConfig() PullRequestConfig {
// See https://docs.github.com/en/enterprise-server@3.6/actions/learn-github-actions/variables#default-environment-variables // See https://docs.github.com/en/enterprise-server@3.6/actions/learn-github-actions/variables#default-environment-variables
githubRef := getEnv("GITHUB_REF", "n/a") githubRef := getEnv("GITHUB_REF", "n/a")
@ -182,6 +211,7 @@ func (g *GitHubActionsConfigProvider) GetPullRequestConfig() PullRequestConfig {
} }
} }
// IsPullRequest indicates whether the current build is triggered by a PR
func (g *GitHubActionsConfigProvider) IsPullRequest() bool { func (g *GitHubActionsConfigProvider) IsPullRequest() bool {
return truthy("GITHUB_HEAD_REF") return truthy("GITHUB_HEAD_REF")
} }
@ -191,26 +221,82 @@ func isGitHubActions() bool {
return areIndicatingEnvVarsSet(envVars) return areIndicatingEnvVarsSet(envVars)
} }
func (g *GitHubActionsConfigProvider) getStageIds() ([]int, error) { // actionsURL returns URL to actions resource. For example,
resp, err := g.client.GetRequest(fmt.Sprintf("%s/runs/%s/jobs", getActionsURL(), getEnv("GITHUB_RUN_ID", "")), httpHeader, nil) // https://api.github.com/repos/SAP/jenkins-library/actions
if err != nil { func actionsURL() string {
return nil, fmt.Errorf("failed to get API data: %w", err) return getEnv("GITHUB_API_URL", "") + "/repos/" + getEnv("GITHUB_REPOSITORY", "") + "/actions"
} }
var stagesID stagesID func (g *GitHubActionsConfigProvider) fetchRunData() {
err = piperHttp.ParseHTTPResponseBodyJSON(resp, &stagesID) if g.runData.fetched {
if err != nil { return
return nil, fmt.Errorf("failed to parse JSON data: %w", err) }
}
url := fmt.Sprintf("%s/runs/%s", actionsURL(), getEnv("GITHUB_RUN_ID", ""))
ids := make([]int, len(stagesID.Jobs)) resp, err := g.client.GetRequest(url, httpHeaders, nil)
for i, job := range stagesID.Jobs { if err != nil || resp.StatusCode != 200 {
ids[i] = job.ID log.Entry().Errorf("failed to get API data: %s", err)
} return
if len(ids) == 0 { }
return nil, fmt.Errorf("failed to get IDs")
} err = piperHttp.ParseHTTPResponseBodyJSON(resp, &g.runData)
if err != nil {
// execution of the last stage hasn't finished yet - we can't get logs of the last stage log.Entry().Errorf("failed to parse JSON data: %s", err)
return ids[:len(stagesID.Jobs)-1], nil return
}
g.runData.fetched = true
}
func (g *GitHubActionsConfigProvider) fetchJobs() error {
if g.jobsFetched {
return nil
}
url := fmt.Sprintf("%s/runs/%s/jobs", actionsURL(), g.GetBuildID())
resp, err := g.client.GetRequest(url, httpHeaders, nil)
if err != nil {
return fmt.Errorf("failed to get API data: %w", err)
}
var result struct {
Jobs []job `json:"jobs"`
}
err = piperHttp.ParseHTTPResponseBodyJSON(resp, &result)
if err != nil {
return fmt.Errorf("failed to parse JSON data: %w", err)
}
if len(result.Jobs) == 0 {
return fmt.Errorf("no jobs found in response")
}
g.jobs = result.Jobs
g.jobsFetched = true
return nil
}
func (g *GitHubActionsConfigProvider) guessCurrentJob() {
// check if the current job has already been guessed
if g.currentJob.ID != 0 {
return
}
// fetch jobs if they haven't been fetched yet
if err := g.fetchJobs(); err != nil {
log.Entry().Errorf("failed to fetch jobs: %s", err)
g.jobs = []job{}
return
}
targetJobName := getEnv("GITHUB_JOB", "unknown")
log.Entry().Debugf("looking for job '%s' in jobs list: %v", targetJobName, g.jobs)
for _, j := range g.jobs {
// j.Name may be something like "piper / Init / Init"
// but GITHUB_JOB env may contain only "Init"
if strings.HasSuffix(j.Name, targetJobName) {
log.Entry().Debugf("current job id: %d", j.ID)
g.currentJob = j
return
}
}
} }

View File

@ -5,9 +5,9 @@ package orchestrator
import ( import (
"fmt" "fmt"
"math/rand"
"net/http" "net/http"
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@ -17,187 +17,328 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGitHubActions(t *testing.T) { func TestGitHubActionsConfigProvider_GetBuildStatus(t *testing.T) {
t.Run("BranchBuild", func(t *testing.T) { tests := []struct {
defer resetEnv(os.Environ()) name string
os.Clearenv() runData run
os.Unsetenv("GITHUB_HEAD_REF") want string
os.Setenv("GITHUB_ACTIONS", "true") }{
os.Setenv("GITHUB_REF", "refs/heads/feat/test-gh-actions") {"BuildStatusSuccess", run{fetched: true, Status: "success"}, BuildStatusSuccess},
os.Setenv("GITHUB_RUN_ID", "42") {"BuildStatusAborted", run{fetched: true, Status: "cancelled"}, BuildStatusAborted},
os.Setenv("GITHUB_SHA", "abcdef42713") {"BuildStatusInProgress", run{fetched: true, Status: "in_progress"}, BuildStatusInProgress},
os.Setenv("GITHUB_SERVER_URL", "github.com") {"BuildStatusFailure", run{fetched: true, Status: "qwertyu"}, BuildStatusFailure},
os.Setenv("GITHUB_REPOSITORY", "foo/bar") {"BuildStatusFailure", run{fetched: true, Status: ""}, BuildStatusFailure},
}
p, _ := NewOrchestratorSpecificConfigProvider() for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.False(t, p.IsPullRequest()) g := &GitHubActionsConfigProvider{
assert.Equal(t, "github.com/foo/bar/actions/runs/42", p.GetBuildURL()) runData: tt.runData,
assert.Equal(t, "feat/test-gh-actions", p.GetBranch()) }
assert.Equal(t, "refs/heads/feat/test-gh-actions", p.GetReference()) assert.Equalf(t, tt.want, g.GetBuildStatus(), "GetBuildStatus()")
assert.Equal(t, "abcdef42713", p.GetCommit()) })
assert.Equal(t, "github.com/foo/bar", p.GetRepoURL()) }
assert.Equal(t, "GitHubActions", p.OrchestratorType()) }
})
func TestGitHubActionsConfigProvider_GetBuildReason(t *testing.T) {
t.Run("PR", func(t *testing.T) { tests := []struct {
defer resetEnv(os.Environ()) name string
os.Clearenv() envGithubRef string
os.Setenv("GITHUB_HEAD_REF", "feat/test-gh-actions") want string
os.Setenv("GITHUB_BASE_REF", "main") }{
os.Setenv("GITHUB_REF", "refs/pull/42/merge") {"BuildReasonManual", "workflow_dispatch", BuildReasonManual},
{"BuildReasonSchedule", "schedule", BuildReasonSchedule},
p := GitHubActionsConfigProvider{} {"BuildReasonPullRequest", "pull_request", BuildReasonPullRequest},
c := p.GetPullRequestConfig() {"BuildReasonResourceTrigger", "workflow_call", BuildReasonResourceTrigger},
{"BuildReasonIndividualCI", "push", BuildReasonIndividualCI},
assert.True(t, p.IsPullRequest()) {"BuildReasonUnknown", "qwerty", BuildReasonUnknown},
assert.Equal(t, "feat/test-gh-actions", c.Branch) {"BuildReasonUnknown", "", BuildReasonUnknown},
assert.Equal(t, "main", c.Base) }
assert.Equal(t, "42", c.Key) for _, tt := range tests {
}) t.Run(tt.name, func(t *testing.T) {
g := &GitHubActionsConfigProvider{}
t.Run("Test get logs - success", func(t *testing.T) {
defer resetEnv(os.Environ()) _ = os.Setenv("GITHUB_EVENT_NAME", tt.envGithubRef)
os.Clearenv() assert.Equalf(t, tt.want, g.GetBuildReason(), "GetBuildReason()")
os.Unsetenv("GITHUB_HEAD_REF") })
os.Setenv("GITHUB_ACTIONS", "true") }
os.Setenv("GITHUB_REF_NAME", "feat/test-gh-actions") }
os.Setenv("GITHUB_REF", "refs/heads/feat/test-gh-actions")
os.Setenv("GITHUB_RUN_ID", "42") func TestGitHubActionsConfigProvider_GetRepoURL(t *testing.T) {
os.Setenv("GITHUB_SHA", "abcdef42713") tests := []struct {
os.Setenv("GITHUB_REPOSITORY", "foo/bar") name string
os.Setenv("GITHUB_URL", "https://github.com/") envServerURL string
p := func() OrchestratorSpecificConfigProviding { envRepo string
g := GitHubActionsConfigProvider{} want string
g.client = piperHttp.Client{} }{
g.client.SetOptions(piperHttp.ClientOptions{ {"github.com", "https://github.com", "SAP/jenkins-library", "https://github.com/SAP/jenkins-library"},
MaxRequestDuration: 5 * time.Second, }
Password: "TOKEN", for _, tt := range tests {
TransportSkipVerification: true, t.Run(tt.name, func(t *testing.T) {
UseDefaultTransport: true, // need to use default transport for http mock g := &GitHubActionsConfigProvider{}
MaxRetries: -1,
}) _ = os.Setenv("GITHUB_SERVER_URL", tt.envServerURL)
return &g _ = os.Setenv("GITHUB_REPOSITORY", tt.envRepo)
}() assert.Equalf(t, tt.want, g.GetRepoURL(), "GetRepoURL()")
stagesID := stagesID{ })
Jobs: []job{ }
{ID: 123}, }
{ID: 124},
{ID: 125}, func TestGitHubActionsConfigProvider_GetPullRequestConfig(t *testing.T) {
}, tests := []struct {
} name string
logs := []string{ envRef string
"log_record1\n", want PullRequestConfig
"log_record2\n", }{
} {"1", "refs/pull/1234/merge", PullRequestConfig{"n/a", "n/a", "1234"}},
httpmock.Activate() {"2", "refs/pull/1234", PullRequestConfig{"n/a", "n/a", "1234"}},
defer httpmock.DeactivateAndReset() {"2", "1234/merge", PullRequestConfig{"n/a", "n/a", "1234"}},
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/runs/42/jobs", }
func(req *http.Request) (*http.Response, error) { for _, tt := range tests {
return httpmock.NewJsonResponse(200, stagesID) t.Run(tt.name, func(t *testing.T) {
}, g := &GitHubActionsConfigProvider{}
)
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/jobs/123/logs", _ = os.Setenv("GITHUB_REF", tt.envRef)
func(req *http.Request) (*http.Response, error) { _ = os.Setenv("GITHUB_HEAD_REF", "n/a")
return httpmock.NewStringResponse(200, string(logs[0])), nil _ = os.Setenv("GITHUB_BASE_REF", "n/a")
}, assert.Equalf(t, tt.want, g.GetPullRequestConfig(), "GetPullRequestConfig()")
) })
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/jobs/124/logs", }
func(req *http.Request) (*http.Response, error) { }
return httpmock.NewStringResponse(200, string(logs[1])), nil
}, func TestGitHubActionsConfigProvider_guessCurrentJob(t *testing.T) {
) tests := []struct {
name string
actual, err := p.GetLog() jobs []job
jobsFetched bool
assert.NoError(t, err) targetJobName string
assert.Equal(t, strings.Join(logs, ""), string(actual)) wantJob job
}) }{
{
t.Run("Test get logs - error: failed to get stages ID", func(t *testing.T) { name: "job found",
defer resetEnv(os.Environ()) jobs: []job{{Name: "Job1"}, {Name: "Job2"}, {Name: "Job3"}},
os.Clearenv() jobsFetched: true,
os.Unsetenv("GITHUB_HEAD_REF") targetJobName: "Job2",
os.Setenv("GITHUB_ACTIONS", "true") wantJob: job{Name: "Job2"},
os.Setenv("GITHUB_REF_NAME", "feat/test-gh-actions") },
os.Setenv("GITHUB_REF", "refs/heads/feat/test-gh-actions") {
os.Setenv("GITHUB_RUN_ID", "42") name: "job found",
os.Setenv("GITHUB_SHA", "abcdef42713") jobs: []job{{Name: "Piper / Job1"}, {Name: "Piper / Job2"}, {Name: "Piper / Job3"}},
os.Setenv("GITHUB_REPOSITORY", "foo/bar") jobsFetched: true,
os.Setenv("GITHUB_URL", "https://github.com/") targetJobName: "Job2",
p := func() OrchestratorSpecificConfigProviding { wantJob: job{Name: "Piper / Job2"},
g := GitHubActionsConfigProvider{} },
g.client = piperHttp.Client{} {
g.client.SetOptions(piperHttp.ClientOptions{ name: "job not found",
MaxRequestDuration: 5 * time.Second, jobs: []job{{Name: "Job1"}, {Name: "Job2"}, {Name: "Job3"}},
Password: "TOKEN", jobsFetched: true,
TransportSkipVerification: true, targetJobName: "Job123",
UseDefaultTransport: true, // need to use default transport for http mock wantJob: job{},
MaxRetries: -1, },
}) }
return &g for _, tt := range tests {
}() t.Run(tt.name, func(t *testing.T) {
httpmock.Activate() g := &GitHubActionsConfigProvider{
defer httpmock.DeactivateAndReset() jobs: tt.jobs,
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/runs/42/jobs", jobsFetched: tt.jobsFetched,
func(req *http.Request) (*http.Response, error) { }
return nil, fmt.Errorf("err") _ = os.Setenv("GITHUB_JOB", tt.targetJobName)
}, g.guessCurrentJob()
)
actual, err := p.GetLog() assert.Equal(t, tt.wantJob, g.currentJob)
})
assert.Nil(t, actual) }
assert.EqualError(t, err, "failed to get API data: HTTP request to https://api.github.com/repos/foo/bar/actions/runs/42/jobs failed with error: HTTP GET request to https://api.github.com/repos/foo/bar/actions/runs/42/jobs failed: Get \"https://api.github.com/repos/foo/bar/actions/runs/42/jobs\": err") }
})
func TestGitHubActionsConfigProvider_fetchRunData(t *testing.T) {
t.Run("Test get logs - failed to get logs", func(t *testing.T) { // data
defer resetEnv(os.Environ()) respJson := map[string]interface{}{
os.Clearenv() "status": "completed",
os.Unsetenv("GITHUB_HEAD_REF") "run_started_at": "2023-08-11T07:28:24Z",
os.Setenv("GITHUB_ACTIONS", "true") "html_url": "https://github.com/SAP/jenkins-library/actions/runs/11111",
os.Setenv("GITHUB_REF_NAME", "feat/test-gh-actions") }
os.Setenv("GITHUB_REF", "refs/heads/feat/test-gh-actions") startedAt, _ := time.Parse(time.RFC3339, "2023-08-11T07:28:24Z")
os.Setenv("GITHUB_RUN_ID", "42") wantRunData := run{
os.Setenv("GITHUB_SHA", "abcdef42713") fetched: true,
os.Setenv("GITHUB_REPOSITORY", "foo/bar") Status: "completed",
os.Setenv("GITHUB_URL", "https://github.com/") StartedAt: startedAt,
p := func() OrchestratorSpecificConfigProviding { }
g := GitHubActionsConfigProvider{}
g.client = piperHttp.Client{} // setup provider
g.client.SetOptions(piperHttp.ClientOptions{ g := &GitHubActionsConfigProvider{}
MaxRequestDuration: 5 * time.Second, g.client.SetOptions(piperHttp.ClientOptions{
Password: "TOKEN", UseDefaultTransport: true, // need to use default transport for http mock
TransportSkipVerification: true, MaxRetries: -1,
UseDefaultTransport: true, // need to use default transport for http mock })
MaxRetries: -1, // setup http mock
}) httpmock.Activate()
return &g defer httpmock.DeactivateAndReset()
}() httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/SAP/jenkins-library/actions/runs/11111",
stagesID := stagesID{ func(req *http.Request) (*http.Response, error) {
Jobs: []job{ return httpmock.NewJsonResponse(200, respJson)
{ID: 123}, },
{ID: 124}, )
{ID: 125}, // setup env vars
}, defer resetEnv(os.Environ())
} os.Clearenv()
httpmock.Activate() _ = os.Setenv("GITHUB_API_URL", "https://api.github.com")
defer httpmock.DeactivateAndReset() _ = os.Setenv("GITHUB_REPOSITORY", "SAP/jenkins-library")
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/runs/42/jobs", _ = os.Setenv("GITHUB_RUN_ID", "11111")
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, stagesID) // run
}, g.fetchRunData()
) assert.Equal(t, wantRunData, g.runData)
httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/foo/bar/actions/jobs/123/logs", }
func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("err") func TestGitHubActionsConfigProvider_fetchJobs(t *testing.T) {
}, // data
) respJson := map[string]interface{}{"jobs": []map[string]interface{}{{
"id": 111,
actual, err := p.GetLog() "name": "Piper / Init",
"html_url": "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/111",
assert.Nil(t, actual) }, {
// GitHubActionsConfigProvider.GetLog calls http.GetRequest concurrently, so we don't know what log (123 or 124) will be got first "id": 222,
// ref: pkg/orchestrator/gitHubActions.go:90 "name": "Piper / Build",
assert.ErrorContains(t, err, "failed to get logs: failed to get API data: HTTP request to https://api.github.com/repos/foo/bar/actions/jobs/12") "html_url": "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/222",
}) }, {
"id": 333,
"name": "Piper / Acceptance",
"html_url": "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/333",
},
}}
wantJobs := []job{{
ID: 111,
Name: "Piper / Init",
HtmlURL: "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/111",
}, {
ID: 222,
Name: "Piper / Build",
HtmlURL: "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/222",
}, {
ID: 333,
Name: "Piper / Acceptance",
HtmlURL: "https://github.com/SAP/jenkins-library/actions/runs/11111/jobs/333",
}}
// setup provider
g := &GitHubActionsConfigProvider{}
g.client.SetOptions(piperHttp.ClientOptions{
UseDefaultTransport: true, // need to use default transport for http mock
MaxRetries: -1,
})
// setup http mock
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder(
http.MethodGet,
"https://api.github.com/repos/SAP/jenkins-library/actions/runs/11111/jobs",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, respJson)
},
)
// setup env vars
defer resetEnv(os.Environ())
os.Clearenv()
_ = os.Setenv("GITHUB_API_URL", "https://api.github.com")
_ = os.Setenv("GITHUB_REPOSITORY", "SAP/jenkins-library")
_ = os.Setenv("GITHUB_RUN_ID", "11111")
// run
err := g.fetchJobs()
assert.NoError(t, err)
assert.Equal(t, wantJobs, g.jobs)
}
func TestGitHubActionsConfigProvider_GetLog(t *testing.T) {
// data
respLogs := []string{
"log_record11\nlog_record12\nlog_record13\n",
"log_record21\nlog_record22\n",
"log_record31\nlog_record32\n",
"log_record41\n",
}
wantLogs := "log_record11\nlog_record12\nlog_record13\nlog_record21\n" +
"log_record22\nlog_record31\nlog_record32\nlog_record41\n"
jobs := []job{
{ID: 111}, {ID: 222}, {ID: 333}, {ID: 444}, {ID: 555},
}
// setup provider
g := &GitHubActionsConfigProvider{
client: piperHttp.Client{},
jobs: jobs,
jobsFetched: true,
}
g.client.SetOptions(piperHttp.ClientOptions{
UseDefaultTransport: true, // need to use default transport for http mock
MaxRetries: -1,
})
// setup http mock
rand.Seed(time.Now().UnixNano())
latencyMin, latencyMax := 15, 500 // milliseconds
httpmock.Activate()
defer httpmock.DeactivateAndReset()
for i, j := range jobs {
idx := i
httpmock.RegisterResponder(
http.MethodGet,
fmt.Sprintf("https://api.github.com/repos/SAP/jenkins-library/actions/jobs/%d/logs", j.ID),
func(req *http.Request) (*http.Response, error) {
// simulate response delay
latency := rand.Intn(latencyMax-latencyMin) + latencyMin
time.Sleep(time.Duration(latency) * time.Millisecond)
return httpmock.NewStringResponse(200, respLogs[idx]), nil
},
)
}
// setup env vars
defer resetEnv(os.Environ())
os.Clearenv()
_ = os.Setenv("GITHUB_API_URL", "https://api.github.com")
_ = os.Setenv("GITHUB_REPOSITORY", "SAP/jenkins-library")
// run
logs, err := g.GetLog()
assert.NoError(t, err)
assert.Equal(t, wantLogs, string(logs))
}
func TestGitHubActionsConfigProvider_Others(t *testing.T) {
defer resetEnv(os.Environ())
os.Clearenv()
_ = os.Setenv("GITHUB_ACTION", "1")
_ = os.Setenv("GITHUB_JOB", "Build")
_ = os.Setenv("GITHUB_RUN_ID", "11111")
_ = os.Setenv("GITHUB_REF_NAME", "main")
_ = os.Setenv("GITHUB_HEAD_REF", "feature-branch-1")
_ = os.Setenv("GITHUB_REF", "refs/pull/42/merge")
_ = os.Setenv("GITHUB_WORKFLOW", "Piper workflow")
_ = os.Setenv("GITHUB_SHA", "ffac537e6cbbf934b08745a378932722df287a53")
_ = os.Setenv("GITHUB_API_URL", "https://api.github.com")
_ = os.Setenv("GITHUB_SERVER_URL", "https://github.com")
_ = os.Setenv("GITHUB_REPOSITORY", "SAP/jenkins-library")
p := GitHubActionsConfigProvider{}
startedAt, _ := time.Parse(time.RFC3339, "2023-08-11T07:28:24Z")
p.runData = run{
fetched: true,
Status: "",
StartedAt: startedAt,
}
p.currentJob = job{ID: 111, Name: "job1", HtmlURL: "https://github.com/SAP/jenkins-library/actions/runs/123456/jobs/7654321"}
assert.Equal(t, "n/a", p.OrchestratorVersion())
assert.Equal(t, "GitHubActions", p.OrchestratorType())
assert.Equal(t, "11111", p.GetBuildID())
assert.Equal(t, []ChangeSet{}, p.GetChangeSet())
assert.Equal(t, startedAt, p.GetPipelineStartTime())
assert.Equal(t, "Build", p.GetStageName())
assert.Equal(t, "main", p.GetBranch())
assert.Equal(t, "refs/pull/42/merge", p.GetReference())
assert.Equal(t, "https://github.com/SAP/jenkins-library/actions/runs/11111", p.GetBuildURL())
assert.Equal(t, "https://github.com/SAP/jenkins-library/actions/runs/123456/jobs/7654321", p.GetJobURL())
assert.Equal(t, "Piper workflow", p.GetJobName())
assert.Equal(t, "ffac537e6cbbf934b08745a378932722df287a53", p.GetCommit())
assert.Equal(t, "https://api.github.com/repos/SAP/jenkins-library/actions", actionsURL())
assert.True(t, p.IsPullRequest())
assert.True(t, isGitHubActions())
} }

View File

@ -14,20 +14,17 @@ import (
type JenkinsConfigProvider struct { type JenkinsConfigProvider struct {
client piperHttp.Client client piperHttp.Client
options piperHttp.ClientOptions
apiInformation map[string]interface{} apiInformation map[string]interface{}
} }
// InitOrchestratorProvider initializes the Jenkins orchestrator with credentials // InitOrchestratorProvider initializes the Jenkins orchestrator with credentials
func (j *JenkinsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) { func (j *JenkinsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) {
j.client = piperHttp.Client{} j.client.SetOptions(piperHttp.ClientOptions{
j.options = piperHttp.ClientOptions{
Username: settings.JenkinsUser, Username: settings.JenkinsUser,
Password: settings.JenkinsToken, Password: settings.JenkinsToken,
MaxRetries: 3, MaxRetries: 3,
TransportTimeout: time.Second * 10, TransportTimeout: time.Second * 10,
} })
j.client.SetOptions(j.options)
log.Entry().Debug("Successfully initialized Jenkins config provider") log.Entry().Debug("Successfully initialized Jenkins config provider")
} }
@ -77,15 +74,15 @@ func (j *JenkinsConfigProvider) GetBuildStatus() string {
// cases in ADO: succeeded, failed, canceled, none, partiallySucceeded // cases in ADO: succeeded, failed, canceled, none, partiallySucceeded
switch result := val; result { switch result := val; result {
case "SUCCESS": case "SUCCESS":
return "SUCCESS" return BuildStatusSuccess
case "ABORTED": case "ABORTED":
return "ABORTED" return BuildStatusAborted
default: default:
// FAILURE, NOT_BUILT // FAILURE, NOT_BUILT
return "FAILURE" return BuildStatusFailure
} }
} }
return "FAILURE" return BuildStatusFailure
} }
// GetChangeSet returns the commitIds and timestamp of the changeSet of the current run // GetChangeSet returns the commitIds and timestamp of the changeSet of the current run
@ -200,12 +197,12 @@ func (j *JenkinsConfigProvider) GetBuildReason() string {
marshal, err := json.Marshal(j.apiInformation) marshal, err := json.Marshal(j.apiInformation)
if err != nil { if err != nil {
log.Entry().WithError(err).Debugf("could not marshal apiInformation") log.Entry().WithError(err).Debugf("could not marshal apiInformation")
return "Unknown" return BuildReasonUnknown
} }
jsonParsed, err := gabs.ParseJSON(marshal) jsonParsed, err := gabs.ParseJSON(marshal)
if err != nil { if err != nil {
log.Entry().WithError(err).Debugf("could not parse apiInformation") log.Entry().WithError(err).Debugf("could not parse apiInformation")
return "Unknown" return BuildReasonUnknown
} }
for _, child := range jsonParsed.Path("actions").Children() { for _, child := range jsonParsed.Path("actions").Children() {
@ -217,21 +214,21 @@ func (j *JenkinsConfigProvider) GetBuildReason() string {
for _, val := range child.Path("causes").Children() { for _, val := range child.Path("causes").Children() {
subclass := val.S("_class") subclass := val.S("_class")
if subclass.Data().(string) == "hudson.model.Cause$UserIdCause" { if subclass.Data().(string) == "hudson.model.Cause$UserIdCause" {
return "Manual" return BuildReasonManual
} else if subclass.Data().(string) == "hudson.triggers.TimerTrigger$TimerTriggerCause" { } else if subclass.Data().(string) == "hudson.triggers.TimerTrigger$TimerTriggerCause" {
return "Schedule" return BuildReasonSchedule
} else if subclass.Data().(string) == "jenkins.branch.BranchEventCause" { } else if subclass.Data().(string) == "jenkins.branch.BranchEventCause" {
return "PullRequest" return BuildReasonPullRequest
} else if subclass.Data().(string) == "org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause" { } else if subclass.Data().(string) == "org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause" {
return "ResourceTrigger" return BuildReasonResourceTrigger
} else { } else {
return "Unknown" return BuildReasonUnknown
} }
} }
} }
} }
return "Unknown" return BuildReasonUnknown
} }
// GetBranch returns the branch name, only works with the git plugin enabled // GetBranch returns the branch name, only works with the git plugin enabled

View File

@ -17,6 +17,20 @@ const (
Jenkins Jenkins
) )
const (
BuildStatusSuccess = "SUCCESS"
BuildStatusAborted = "ABORTED"
BuildStatusFailure = "FAILURE"
BuildStatusInProgress = "IN_PROGRESS"
BuildReasonManual = "Manual"
BuildReasonSchedule = "Schedule"
BuildReasonPullRequest = "PullRequest"
BuildReasonResourceTrigger = "ResourceTrigger"
BuildReasonIndividualCI = "IndividualCI"
BuildReasonUnknown = "Unknown"
)
type OrchestratorSpecificConfigProviding interface { type OrchestratorSpecificConfigProviding interface {
InitOrchestratorProvider(settings *OrchestratorSettings) InitOrchestratorProvider(settings *OrchestratorSettings)
OrchestratorType() string OrchestratorType() string
@ -75,13 +89,13 @@ func NewOrchestratorSpecificConfigProvider() (OrchestratorSpecificConfigProvidin
// DetectOrchestrator returns the name of the current orchestrator e.g. Jenkins, Azure, Unknown // DetectOrchestrator returns the name of the current orchestrator e.g. Jenkins, Azure, Unknown
func DetectOrchestrator() Orchestrator { func DetectOrchestrator() Orchestrator {
if isAzure() { if isAzure() {
return Orchestrator(AzureDevOps) return AzureDevOps
} else if isGitHubActions() { } else if isGitHubActions() {
return Orchestrator(GitHubActions) return GitHubActions
} else if isJenkins() { } else if isJenkins() {
return Orchestrator(Jenkins) return Jenkins
} else { } else {
return Orchestrator(Unknown) return Unknown
} }
} }