diff --git a/README.md b/README.md index 273c86d..c79d544 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ A [Drone](https://github.com/drone/drone) plugin for triggering [Jenkins](https: - Trigger single or multiple Jenkins jobs - Support for Jenkins build parameters - Multiple authentication methods (API token or remote trigger token) +- Wait for job completion with configurable polling and timeout - SSL/TLS support with optional insecure mode - Cross-platform support (Linux, macOS, Windows) - Available as binary, Docker image, or Drone plugin @@ -123,15 +124,18 @@ Alternatively, you can use a remote trigger token configured in your Jenkins job ### Parameters Reference -| Parameter | CLI Flag | Environment Variable | Required | Description | -| ------------ | -------------------- | --------------------------------------------- | ------------- | ------------------------------------------------------ | -| Host | `--host` | `PLUGIN_URL`, `JENKINS_URL` | Yes | Jenkins base URL (e.g., `http://jenkins.example.com/`) | -| User | `--user`, `-u` | `PLUGIN_USER`, `JENKINS_USER` | Conditional\* | Jenkins username | -| Token | `--token`, `-t` | `PLUGIN_TOKEN`, `JENKINS_TOKEN` | Conditional\* | Jenkins API token | -| Remote Token | `--remote-token` | `PLUGIN_REMOTE_TOKEN`, `JENKINS_REMOTE_TOKEN` | Conditional\* | Jenkins remote trigger token | -| Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | Yes | Jenkins job name(s) - can specify multiple | -| Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | No | Build parameters in `key=value` format | -| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | No | Allow insecure SSL connections (default: false) | +| Parameter | CLI Flag | Environment Variable | Required | Description | +| ------------- | -------------------- | ----------------------------------------------- | ------------- | ------------------------------------------------------ | +| Host | `--host` | `PLUGIN_URL`, `JENKINS_URL` | Yes | Jenkins base URL (e.g., `http://jenkins.example.com/`) | +| User | `--user`, `-u` | `PLUGIN_USER`, `JENKINS_USER` | Conditional\* | Jenkins username | +| Token | `--token`, `-t` | `PLUGIN_TOKEN`, `JENKINS_TOKEN` | Conditional\* | Jenkins API token | +| Remote Token | `--remote-token` | `PLUGIN_REMOTE_TOKEN`, `JENKINS_REMOTE_TOKEN` | Conditional\* | Jenkins remote trigger token | +| Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | Yes | Jenkins job name(s) - can specify multiple | +| Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | No | Build parameters in `key=value` format | +| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | No | Allow insecure SSL connections (default: false) | +| Wait | `--wait` | `PLUGIN_WAIT`, `JENKINS_WAIT` | No | Wait for job completion (default: false) | +| Poll Interval | `--poll-interval` | `PLUGIN_POLL_INTERVAL`, `JENKINS_POLL_INTERVAL` | No | Interval between status checks (default: 10s) | +| Timeout | `--timeout` | `PLUGIN_TIMEOUT`, `JENKINS_TIMEOUT` | No | Maximum time to wait for job completion (default: 30m) | **Authentication Requirements**: You must provide either: @@ -184,6 +188,19 @@ drone-jenkins \ --job my-jenkins-job ``` +**Wait for job completion:** + +```bash +drone-jenkins \ + --host http://jenkins.example.com/ \ + --user appleboy \ + --token XXXXXXXX \ + --job my-jenkins-job \ + --wait \ + --poll-interval 15s \ + --timeout 1h +``` + ### Docker **Single job:** @@ -220,6 +237,20 @@ docker run --rm \ ghcr.io/appleboy/drone-jenkins ``` +**Wait for job completion:** + +```bash +docker run --rm \ + -e JENKINS_URL=http://jenkins.example.com/ \ + -e JENKINS_USER=appleboy \ + -e JENKINS_TOKEN=xxxxxxx \ + -e JENKINS_JOB=my-jenkins-job \ + -e JENKINS_WAIT=true \ + -e JENKINS_POLL_INTERVAL=15s \ + -e JENKINS_TIMEOUT=1h \ + ghcr.io/appleboy/drone-jenkins +``` + ### Drone CI Add the plugin to your `.drone.yml`: @@ -272,6 +303,23 @@ steps: job: my-jenkins-job ``` +**Wait for job completion:** + +```yaml +steps: + - name: trigger-jenkins + image: ghcr.io/appleboy/drone-jenkins + settings: + url: http://jenkins.example.com/ + user: appleboy + token: + from_secret: jenkins_token + job: deploy-production + wait: true + poll_interval: 15s + timeout: 1h +``` + For more detailed examples and advanced configurations, see [DOCS.md](DOCS.md). ## Development diff --git a/jenkins.go b/jenkins.go index 2ba756c..3b18a59 100644 --- a/jenkins.go +++ b/jenkins.go @@ -6,9 +6,11 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strings" + "time" ) type ( @@ -25,6 +27,29 @@ type ( Token string // Remote trigger token Client *http.Client } + + // QueueItem represents a Jenkins queue item response + QueueItem struct { + Blocked bool `json:"blocked"` + Buildable bool `json:"buildable"` + ID int `json:"id"` + InQueueSince int64 `json:"inQueueSince"` + Executable *struct { + Number int `json:"number"` + URL string `json:"url"` + } `json:"executable"` + Why string `json:"why"` + } + + // BuildInfo represents Jenkins build information + BuildInfo struct { + Building bool `json:"building"` + Duration int64 `json:"duration"` + Result string `json:"result"` // SUCCESS, FAILURE, ABORTED, UNSTABLE, null if building + Number int `json:"number"` + URL string `json:"url"` + Timestamp int64 `json:"timestamp"` + } ) // NewJenkins is initial Jenkins object @@ -68,17 +93,17 @@ func (jenkins *Jenkins) sendRequest(req *http.Request) (*http.Response, error) { return jenkins.Client.Do(req) } -func (jenkins *Jenkins) post(path string, params url.Values, body interface{}) (err error) { +func (jenkins *Jenkins) get(path string, params url.Values, body interface{}) error { requestURL := jenkins.buildURL(path, params) - req, err := http.NewRequestWithContext(context.Background(), "POST", requestURL, nil) + req, err := http.NewRequestWithContext(context.Background(), "GET", requestURL, nil) if err != nil { - return + return err } resp, err := jenkins.sendRequest(req) if err != nil { - return + return err } defer resp.Body.Close() @@ -87,7 +112,7 @@ func (jenkins *Jenkins) post(path string, params url.Values, body interface{}) ( return fmt.Errorf("failed to read response body: %w", err) } - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected response code: %d, body: %s", resp.StatusCode, string(data)) } @@ -98,6 +123,61 @@ func (jenkins *Jenkins) post(path string, params url.Values, body interface{}) ( return json.Unmarshal(data, body) } +// postAndGetLocation performs a POST request and extracts the queue ID from Location header +func (jenkins *Jenkins) postAndGetLocation(path string, params url.Values) (int, error) { + requestURL := jenkins.buildURL(path, params) + + req, err := http.NewRequestWithContext(context.Background(), "POST", requestURL, nil) + if err != nil { + return 0, err + } + + resp, err := jenkins.sendRequest(req) + if err != nil { + return 0, err + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf( + "unexpected response code: %d, body: %s", + resp.StatusCode, + string(data), + ) + } + + // Extract queue ID from Location header + // Location format: http://jenkins.example.com/queue/item/123/ + location := resp.Header.Get("Location") + if location == "" { + return 0, fmt.Errorf("no Location header in response") + } + + // Parse queue ID from URL + // Look for /queue/item/{id}/ or /queue/item/{id} + var queueID int + // Find the pattern "/queue/item/" and extract the number after it + queueItemPrefix := "/queue/item/" + idx := strings.Index(location, queueItemPrefix) + if idx == -1 { + return 0, fmt.Errorf("failed to parse queue ID from Location: %s", location) + } + + // Extract the substring after "/queue/item/" + afterPrefix := location[idx+len(queueItemPrefix):] + // Parse the number (stop at / or end of string) + if _, err := fmt.Sscanf(afterPrefix, "%d", &queueID); err != nil { + return 0, fmt.Errorf("failed to parse queue ID from Location: %s", location) + } + + return queueID, nil +} + func (jenkins *Jenkins) parseJobPath(job string) string { var path string @@ -115,7 +195,108 @@ func (jenkins *Jenkins) parseJobPath(job string) string { return path } -func (jenkins *Jenkins) trigger(job string, params url.Values) error { +// getQueueItem fetches information about a queue item +func (jenkins *Jenkins) getQueueItem(queueID int) (*QueueItem, error) { + path := fmt.Sprintf("/queue/item/%d/api/json", queueID) + + var queueItem QueueItem + err := jenkins.get(path, nil, &queueItem) + if err != nil { + return nil, fmt.Errorf("failed to get queue item %d: %w", queueID, err) + } + + return &queueItem, nil +} + +// getBuildInfo fetches information about a specific build +func (jenkins *Jenkins) getBuildInfo(job string, buildNumber int) (*BuildInfo, error) { + path := fmt.Sprintf("%s/%d/api/json", jenkins.parseJobPath(job), buildNumber) + + var buildInfo BuildInfo + err := jenkins.get(path, nil, &buildInfo) + if err != nil { + return nil, fmt.Errorf("failed to get build info for %s #%d: %w", job, buildNumber, err) + } + + return &buildInfo, nil +} + +// waitForCompletion waits for a Jenkins build to complete +// It first polls the queue to get the build number, then polls the build status until completion +func (jenkins *Jenkins) waitForCompletion( + job string, + queueID int, + pollInterval, timeout time.Duration, +) (*BuildInfo, error) { + deadline := time.Now().Add(timeout) + + // Phase 1: Wait for queue item to be assigned a build number + log.Printf("waiting for job %s (queue #%d) to start...", job, queueID) + var buildNumber int + + for { + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout waiting for job %s to start", job) + } + + queueItem, err := jenkins.getQueueItem(queueID) + if err != nil { + // Queue item might be deleted after build starts, try to continue + log.Printf("warning: failed to get queue item: %v", err) + time.Sleep(pollInterval) + continue + } + + // Check if build has started + if queueItem.Executable != nil && queueItem.Executable.Number > 0 { + buildNumber = queueItem.Executable.Number + log.Printf("job %s started as build #%d", job, buildNumber) + break + } + + // Log why the job is waiting if available + if queueItem.Why != "" { + log.Printf("job %s is queued: %s", job, queueItem.Why) + } + + time.Sleep(pollInterval) + } + + // Phase 2: Wait for build to complete + log.Printf("waiting for job %s (build #%d) to complete...", job, buildNumber) + + for { + if time.Now().After(deadline) { + return nil, fmt.Errorf( + "timeout waiting for job %s build #%d to complete", + job, + buildNumber, + ) + } + + buildInfo, err := jenkins.getBuildInfo(job, buildNumber) + if err != nil { + log.Printf("warning: failed to get build info: %v", err) + time.Sleep(pollInterval) + continue + } + + // Check if build is complete + if !buildInfo.Building { + log.Printf( + "job %s (build #%d) completed with status: %s", + job, + buildNumber, + buildInfo.Result, + ) + return buildInfo, nil + } + + time.Sleep(pollInterval) + } +} + +func (jenkins *Jenkins) trigger(job string, params url.Values) (int, error) { // Add remote trigger token to params if jenkins.Token != "" { if params == nil { @@ -141,5 +322,6 @@ func (jenkins *Jenkins) trigger(job string, params url.Values) error { } // All params (including token) are passed as query parameters - return jenkins.post(urlPath, params, nil) + // Returns the queue item ID for tracking + return jenkins.postAndGetLocation(urlPath, params) } diff --git a/jenkins_test.go b/jenkins_test.go index 8fba659..942b9e2 100644 --- a/jenkins_test.go +++ b/jenkins_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -29,8 +31,9 @@ func TestUnSupportProtocol(t *testing.T) { } jenkins := NewJenkins(auth, "example.com", "", false) - err := jenkins.trigger("drone-jenkins", nil) + queueID, err := jenkins.trigger("drone-jenkins", nil) assert.NotNil(t, err) + assert.Equal(t, 0, queueID) } func TestTriggerBuild(t *testing.T) { @@ -38,7 +41,8 @@ func TestTriggerBuild(t *testing.T) { var receivedParams url.Values server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedParams = r.URL.Query() - w.WriteHeader(http.StatusOK) + w.Header().Set("Location", "http://jenkins.example.com/queue/item/123/") + w.WriteHeader(http.StatusCreated) })) defer server.Close() @@ -49,9 +53,415 @@ func TestTriggerBuild(t *testing.T) { jenkins := NewJenkins(auth, server.URL, "remote-token", false) params := url.Values{"param": []string{"value"}} - err := jenkins.trigger("drone-jenkins", params) + queueID, err := jenkins.trigger("drone-jenkins", params) assert.NoError(t, err) + assert.Equal(t, 123, queueID) assert.Equal(t, "value", receivedParams.Get("param")) assert.Equal(t, "remote-token", receivedParams.Get("token")) } + +func TestPostAndGetLocation(t *testing.T) { + tests := []struct { + name string + location string + expectID int + expectError bool + }{ + { + name: "valid location with trailing slash", + location: "http://jenkins.example.com/queue/item/456/", + expectID: 456, + expectError: false, + }, + { + name: "valid location without trailing slash", + location: "http://jenkins.example.com/queue/item/789", + expectID: 789, + expectError: false, + }, + { + name: "no location header", + location: "", + expectID: 0, + expectError: true, + }, + { + name: "invalid location format", + location: "http://jenkins.example.com/invalid/path", + expectID: 0, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tt.location != "" { + w.Header().Set("Location", tt.location) + } + w.WriteHeader(http.StatusCreated) + }), + ) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + queueID, err := jenkins.postAndGetLocation("/test", nil) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectID, queueID) + } + }) + } +} + +func TestGetQueueItem(t *testing.T) { + tests := []struct { + name string + queueID int + responseBody string + responseStatus int + expectError bool + expectBlocked bool + expectBuildNum int + }{ + { + name: "queue item with build number", + queueID: 123, + responseBody: `{"id":123,"blocked":false,"buildable":true,` + + `"executable":{"number":456,"url":"http://jenkins.example.com/job/test/456/"}}`, + responseStatus: http.StatusOK, + expectError: false, + expectBlocked: false, + expectBuildNum: 456, + }, + { + name: "queue item waiting", + queueID: 124, + responseBody: `{"id":124,"blocked":false,"buildable":true,"why":"Waiting for executor"}`, + responseStatus: http.StatusOK, + expectError: false, + expectBlocked: false, + expectBuildNum: 0, + }, + { + name: "queue item blocked", + queueID: 125, + responseBody: `{"id":125,"blocked":true,"buildable":false,"why":"Blocked by other job"}`, + responseStatus: http.StatusOK, + expectError: false, + expectBlocked: true, + expectBuildNum: 0, + }, + { + name: "queue item not found", + queueID: 999, + responseBody: "Not Found", + responseStatus: http.StatusNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/queue/item/") + w.WriteHeader(tt.responseStatus) + _, _ = w.Write([]byte(tt.responseBody)) + }), + ) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + queueItem, err := jenkins.getQueueItem(tt.queueID) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, queueItem) + assert.Equal(t, tt.queueID, queueItem.ID) + assert.Equal(t, tt.expectBlocked, queueItem.Blocked) + if queueItem.Executable != nil { + assert.Equal(t, tt.expectBuildNum, queueItem.Executable.Number) + } + } + }) + } +} + +func TestGetBuildInfo(t *testing.T) { + tests := []struct { + name string + jobName string + buildNumber int + responseBody string + responseStatus int + expectError bool + expectBuilding bool + expectResult string + }{ + { + name: "build in progress", + jobName: "test-job", + buildNumber: 123, + responseBody: `{"number":123,"building":true,"duration":0,"result":null,` + + `"url":"http://jenkins.example.com/job/test-job/123/"}`, + responseStatus: http.StatusOK, + expectError: false, + expectBuilding: true, + expectResult: "", + }, + { + name: "build completed successfully", + jobName: "test-job", + buildNumber: 124, + responseBody: `{"number":124,"building":false,"duration":5000,"result":"SUCCESS",` + + `"url":"http://jenkins.example.com/job/test-job/124/"}`, + responseStatus: http.StatusOK, + expectError: false, + expectBuilding: false, + expectResult: "SUCCESS", + }, + { + name: "build failed", + jobName: "test-job", + buildNumber: 125, + responseBody: `{"number":125,"building":false,"duration":3000,"result":"FAILURE",` + + `"url":"http://jenkins.example.com/job/test-job/125/"}`, + responseStatus: http.StatusOK, + expectError: false, + expectBuilding: false, + expectResult: "FAILURE", + }, + { + name: "build not found", + jobName: "test-job", + buildNumber: 999, + responseBody: "Not Found", + responseStatus: http.StatusNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/job/") + w.WriteHeader(tt.responseStatus) + _, _ = w.Write([]byte(tt.responseBody)) + }), + ) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + buildInfo, err := jenkins.getBuildInfo(tt.jobName, tt.buildNumber) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, buildInfo) + assert.Equal(t, tt.buildNumber, buildInfo.Number) + assert.Equal(t, tt.expectBuilding, buildInfo.Building) + assert.Equal(t, tt.expectResult, buildInfo.Result) + } + }) + } +} + +func TestWaitForCompletion(t *testing.T) { + t.Run("successful completion", func(t *testing.T) { + var callCount int32 + queueID := 123 + buildNumber := 456 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + + switch r.URL.Path { + case testQueueItemPath: + // First call: queue item without build number + // Second call: queue item with build number + w.WriteHeader(http.StatusOK) + if count == 1 { + _, _ = w.Write([]byte( + `{"id":123,"blocked":false,"buildable":true,"why":"Waiting for executor"}`, + )) + } else { + _, _ = w.Write([]byte(`{"id":123,"blocked":false,"buildable":true,` + + `"executable":{"number":456,"url":"http://example.com/job/test/456/"}}`)) + } + case testBuildStatusPath: + // First call: build in progress + // Second call: build completed + w.WriteHeader(http.StatusOK) + if count <= 3 { + _, _ = w.Write( + []byte(`{"number":456,"building":true,"duration":0,"result":null}`), + ) + } else { + _, _ = w.Write([]byte(`{"number":456,"building":false,"duration":5000,"result":"SUCCESS"}`)) + } + } + })) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + buildInfo, err := jenkins.waitForCompletion( + "test-job", + queueID, + 100*time.Millisecond, + 5*time.Second, + ) + + assert.NoError(t, err) + assert.NotNil(t, buildInfo) + assert.Equal(t, buildNumber, buildInfo.Number) + assert.False(t, buildInfo.Building) + assert.Equal(t, "SUCCESS", buildInfo.Result) + }) + + t.Run("timeout waiting for queue", func(t *testing.T) { + queueID := 123 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always return queue item without build number + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte(`{"id":123,"blocked":false,"buildable":true,"why":"Waiting forever"}`), + ) + })) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + buildInfo, err := jenkins.waitForCompletion( + "test-job", + queueID, + 50*time.Millisecond, + 200*time.Millisecond, + ) + + assert.Error(t, err) + assert.Nil(t, buildInfo) + assert.Contains(t, err.Error(), "timeout") + }) + + t.Run("timeout waiting for build", func(t *testing.T) { + var callCount int32 + queueID := 123 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + + switch r.URL.Path { + case testQueueItemPath: + // Return build number immediately + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":123,"blocked":false,"buildable":true,` + + `"executable":{"number":456,"url":"http://example.com/job/test/456/"}}`)) + case testBuildStatusPath: + // Always return building status + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"number":456,"building":true,"duration":0,"result":null}`)) + } + _ = count + })) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + buildInfo, err := jenkins.waitForCompletion( + "test-job", + queueID, + 50*time.Millisecond, + 200*time.Millisecond, + ) + + assert.Error(t, err) + assert.Nil(t, buildInfo) + assert.Contains(t, err.Error(), "timeout") + }) + + t.Run("build failed", func(t *testing.T) { + var callCount int32 + queueID := 123 + buildNumber := 456 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + + switch r.URL.Path { + case testQueueItemPath: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":123,"blocked":false,"buildable":true,` + + `"executable":{"number":456,"url":"http://example.com/job/test/456/"}}`)) + case testBuildStatusPath: + // First call: building, second call: failed + w.WriteHeader(http.StatusOK) + if count == 1 { + _, _ = w.Write( + []byte(`{"number":456,"building":true,"duration":0,"result":null}`), + ) + } else { + _, _ = w.Write([]byte(`{"number":456,"building":false,"duration":3000,"result":"FAILURE"}`)) + } + } + })) + defer server.Close() + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins := NewJenkins(auth, server.URL, "", false) + + buildInfo, err := jenkins.waitForCompletion( + "test-job", + queueID, + 50*time.Millisecond, + 5*time.Second, + ) + + assert.NoError(t, err) + assert.NotNil(t, buildInfo) + assert.Equal(t, buildNumber, buildInfo.Number) + assert.False(t, buildInfo.Building) + assert.Equal(t, "FAILURE", buildInfo.Result) + }) +} diff --git a/main.go b/main.go index f712589..1f76392 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "time" "github.com/joho/godotenv" "github.com/urfave/cli/v2" @@ -88,6 +89,27 @@ func main() { Usage: "jenkins build parameters", EnvVars: []string{"PLUGIN_PARAMETERS", "JENKINS_PARAMETERS", "INPUT_PARAMETERS"}, }, + &cli.BoolFlag{ + Name: "wait", + Usage: "wait for job completion", + EnvVars: []string{"PLUGIN_WAIT", "JENKINS_WAIT", "INPUT_WAIT"}, + }, + &cli.DurationFlag{ + Name: "poll-interval", + Usage: "interval between status checks (e.g., 10s, 1m)", + Value: 10 * time.Second, + EnvVars: []string{ + "PLUGIN_POLL_INTERVAL", + "JENKINS_POLL_INTERVAL", + "INPUT_POLL_INTERVAL", + }, + }, + &cli.DurationFlag{ + Name: "timeout", + Usage: "maximum time to wait for job completion (e.g., 30m, 1h)", + Value: 30 * time.Minute, + EnvVars: []string{"PLUGIN_TIMEOUT", "JENKINS_TIMEOUT", "INPUT_TIMEOUT"}, + }, } // Override a template @@ -142,13 +164,16 @@ func run(c *cli.Context) error { } plugin := Plugin{ - BaseURL: c.String("host"), - Username: c.String("user"), - Token: c.String("token"), - RemoteToken: c.String("remote-token"), - Job: c.StringSlice("job"), - Insecure: c.Bool("insecure"), - Parameters: c.StringSlice("parameters"), + BaseURL: c.String("host"), + Username: c.String("user"), + Token: c.String("token"), + RemoteToken: c.String("remote-token"), + Job: c.StringSlice("job"), + Insecure: c.Bool("insecure"), + Parameters: c.StringSlice("parameters"), + Wait: c.Bool("wait"), + PollInterval: c.Duration("poll-interval"), + Timeout: c.Duration("timeout"), } return plugin.Exec() diff --git a/plugin.go b/plugin.go index 1623c29..146236b 100644 --- a/plugin.go +++ b/plugin.go @@ -6,19 +6,23 @@ import ( "log" "net/url" "strings" + "time" ) type ( // Plugin represents the configuration for the Jenkins plugin. // It contains all necessary credentials and settings to trigger Jenkins jobs. Plugin struct { - BaseURL string // Jenkins server base URL - Username string // Jenkins username for authentication - Token string // Jenkins API token for authentication - RemoteToken string // Optional remote trigger token for additional security - Job []string // List of Jenkins job names to trigger - Insecure bool // Whether to skip TLS certificate verification - Parameters []string // Job parameters in key=value format + BaseURL string // Jenkins server base URL + Username string // Jenkins username for authentication + Token string // Jenkins API token for authentication + RemoteToken string // Optional remote trigger token for additional security + Job []string // List of Jenkins job names to trigger + Insecure bool // Whether to skip TLS certificate verification + Parameters []string // Job parameters in key=value format + Wait bool // Whether to wait for job completion + PollInterval time.Duration // Interval between status checks (default: 10s) + Timeout time.Duration // Maximum time to wait for job completion (default: 30m) } ) @@ -105,12 +109,44 @@ func (p Plugin) Exec() error { // Parse job parameters params := parseParameters(p.Parameters) + // Set default values for wait configuration + pollInterval := p.PollInterval + if pollInterval == 0 { + pollInterval = 10 * time.Second + } + + timeout := p.Timeout + if timeout == 0 { + timeout = 30 * time.Minute + } + // Trigger each job for _, jobName := range jobs { - if err := jenkins.trigger(jobName, params); err != nil { + queueID, err := jenkins.trigger(jobName, params) + if err != nil { return fmt.Errorf("failed to trigger job %q: %w", jobName, err) } - log.Printf("successfully triggered job: %s", jobName) + log.Printf("successfully triggered job: %s (queue #%d)", jobName, queueID) + + // Wait for job completion if requested + if p.Wait { + buildInfo, err := jenkins.waitForCompletion(jobName, queueID, pollInterval, timeout) + if err != nil { + return fmt.Errorf("error waiting for job %q: %w", jobName, err) + } + + // Check if build was successful + if buildInfo.Result != "SUCCESS" { + return fmt.Errorf( + "job %q (build #%d) failed with status: %s", + jobName, + buildInfo.Number, + buildInfo.Result, + ) + } + + log.Printf("job %s (build #%d) completed successfully", jobName, buildInfo.Number) + } } return nil diff --git a/plugin_test.go b/plugin_test.go index 4e1bd72..6845968 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "net/url" @@ -9,6 +10,12 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + testJobBuildPath = "/job/test-job/build" + testQueueItemPath = "/queue/item/123/api/json" + testBuildStatusPath = "/job/test-job/456/api/json" +) + // TestValidateConfig tests the validateConfig method func TestValidateConfig(t *testing.T) { tests := []struct { @@ -264,8 +271,12 @@ func TestExecMissingJenkinsJob(t *testing.T) { // TestExecTriggerBuild tests successful job triggering func TestExecTriggerBuild(t *testing.T) { // Create a mock Jenkins server + queueID := 1 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + w.Header(). + Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", queueID)) + w.WriteHeader(http.StatusCreated) + queueID++ })) defer server.Close() @@ -287,7 +298,9 @@ func TestExecTriggerMultipleJobs(t *testing.T) { jobsTriggered := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { jobsTriggered++ - w.WriteHeader(http.StatusOK) + w.Header(). + Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered)) + w.WriteHeader(http.StatusCreated) })) defer server.Close() @@ -310,7 +323,8 @@ func TestExecWithParameters(t *testing.T) { var receivedQuery url.Values server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedQuery = r.URL.Query() - w.WriteHeader(http.StatusOK) + w.Header().Set("Location", "http://jenkins.example.com/queue/item/1/") + w.WriteHeader(http.StatusCreated) })) defer server.Close() @@ -335,7 +349,8 @@ func TestExecWithRemoteToken(t *testing.T) { var receivedToken string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedToken = r.URL.Query().Get("token") - w.WriteHeader(http.StatusOK) + w.Header().Set("Location", "http://jenkins.example.com/queue/item/1/") + w.WriteHeader(http.StatusCreated) })) defer server.Close() @@ -359,7 +374,9 @@ func TestExecWithJobsContainingWhitespace(t *testing.T) { jobsTriggered := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { jobsTriggered++ - w.WriteHeader(http.StatusOK) + w.Header(). + Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered)) + w.WriteHeader(http.StatusCreated) })) defer server.Close() @@ -376,3 +393,72 @@ func TestExecWithJobsContainingWhitespace(t *testing.T) { // Should trigger 3 jobs (whitespace-only entry should be filtered out) assert.Equal(t, 3, jobsTriggered) } + +// TestExecWithWaitSuccess tests job execution with wait for successful completion +func TestExecWithWaitSuccess(t *testing.T) { + // Create a mock Jenkins server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testJobBuildPath: + // Trigger build + w.Header().Set("Location", "http://jenkins.example.com/queue/item/123/") + w.WriteHeader(http.StatusCreated) + case testQueueItemPath: + // Queue item with build number + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":123,"executable":{"number":456}}`)) + case testBuildStatusPath: + // Build completed successfully + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"number":456,"building":false,"result":"SUCCESS"}`)) + } + })) + defer server.Close() + + plugin := Plugin{ + BaseURL: server.URL, + Username: "foo", + Token: "bar", + Job: []string{"test-job"}, + Wait: true, + } + + err := plugin.Exec() + + assert.NoError(t, err) +} + +// TestExecWithWaitFailure tests job execution with wait for failed build +func TestExecWithWaitFailure(t *testing.T) { + // Create a mock Jenkins server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testJobBuildPath: + // Trigger build + w.Header().Set("Location", "http://jenkins.example.com/queue/item/123/") + w.WriteHeader(http.StatusCreated) + case testQueueItemPath: + // Queue item with build number + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":123,"executable":{"number":456}}`)) + case testBuildStatusPath: + // Build completed with failure + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"number":456,"building":false,"result":"FAILURE"}`)) + } + })) + defer server.Close() + + plugin := Plugin{ + BaseURL: server.URL, + Username: "foo", + Token: "bar", + Job: []string{"test-job"}, + Wait: true, + } + + err := plugin.Exec() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed with status: FAILURE") +}