mirror of
https://github.com/appleboy/drone-jenkins.git
synced 2026-06-04 10:15:02 +08:00
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:
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user