feat: add configurable Jenkins job wait and polling support (#40)

- Add support for waiting for Jenkins job completion with polling and timeout options
- Introduce flags and environment variables to control wait behavior, poll interval, and timeout
- Refactor the Jenkins job trigger to return queue item ID and enable job status tracking
- Implement functions for querying queue items and build status, including waiting until a build completes or times out
- Update CLI, plugin, and documentation to explain new job wait and polling capabilities
- Extend tests to cover queue item parsing, build info retrieval, and job completion scenarios with success, failure, and timeouts

Signed-off-by: appleboy <appleboy.tw@gmail.com>
This commit is contained in:
Bo-Yi Wu
2025-12-01 21:53:42 +08:00
committed by GitHub
parent 747d7b23d1
commit 627e233cc6
6 changed files with 827 additions and 40 deletions
+57 -9
View File
@@ -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
+189 -7
View File
@@ -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)
}
+413 -3
View File
@@ -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)
})
}
+32 -7
View File
@@ -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()
+45 -9
View File
@@ -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
+91 -5
View File
@@ -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")
}