mirror of
https://github.com/appleboy/drone-jenkins.git
synced 2026-06-16 14:49:16 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02829360ad | |||
| f3a67c62a6 | |||
| 069e6455cc | |||
| eb51e55e81 | |||
| da87ddb86b | |||
| 60874908e6 | |||
| f6e62d9c49 | |||
| cf9b9a0a0d | |||
| 4c54d13899 | |||
| 627e233cc6 |
@@ -48,6 +48,37 @@ Example configuration with jobs in the folder:
|
||||
|
||||
It will trigger the URL of Jenkins job like as `http://example.com/job/folder_name/job/job_name/`
|
||||
|
||||
Example configuration with build parameters:
|
||||
|
||||
```yaml
|
||||
- name: trigger jenkins job
|
||||
image: appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://example.com
|
||||
user: appleboy
|
||||
token: xxxxxxxxxx
|
||||
job: parameterized-job
|
||||
parameters: |
|
||||
ENVIRONMENT=production
|
||||
VERSION=${DRONE_TAG}
|
||||
COMMIT_SHA=${DRONE_COMMIT_SHA}
|
||||
```
|
||||
|
||||
Example configuration with wait for completion:
|
||||
|
||||
```yaml
|
||||
- name: trigger jenkins job and wait
|
||||
image: appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://example.com
|
||||
user: appleboy
|
||||
token: xxxxxxxxxx
|
||||
job: deploy-job
|
||||
wait: true
|
||||
poll_interval: 15s
|
||||
timeout: 1h
|
||||
```
|
||||
|
||||
## Parameter Reference
|
||||
|
||||
url
|
||||
@@ -61,3 +92,21 @@ token
|
||||
|
||||
job
|
||||
: jenkins job name
|
||||
|
||||
parameters
|
||||
: build parameters in multi-line `key=value` format (one per line)
|
||||
|
||||
wait
|
||||
: wait for job completion (default: false)
|
||||
|
||||
poll_interval
|
||||
: interval between status checks when waiting (default: 10s)
|
||||
|
||||
timeout
|
||||
: maximum time to wait for job completion (default: 30m)
|
||||
|
||||
insecure
|
||||
: allow insecure SSL connections (default: false)
|
||||
|
||||
remote_token
|
||||
: jenkins remote trigger token (alternative to user/token authentication)
|
||||
|
||||
@@ -39,7 +39,9 @@ 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)
|
||||
- SSL/TLS support with optional insecure mode
|
||||
- Wait for job completion with configurable polling and timeout
|
||||
- Debug mode with detailed parameter information and secure token masking
|
||||
- SSL/TLS support with custom CA certificates (PEM content, file path, or URL)
|
||||
- Cross-platform support (Linux, macOS, Windows)
|
||||
- Available as binary, Docker image, or Drone plugin
|
||||
|
||||
@@ -96,16 +98,9 @@ docker pull ghcr.io/appleboy/drone-jenkins
|
||||
Set up a Jenkins server using Docker:
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
--name jenkins \
|
||||
-d --restart always \
|
||||
-p 8080:8080 -p 50000:50000 \
|
||||
-v /data/jenkins:/var/jenkins_home \
|
||||
jenkins/jenkins:lts
|
||||
docker run -d -v jenkins_home:/var/jenkins_home -p 8080:8080 -p 50000:50000 --restart=on-failure jenkins/jenkins:slim
|
||||
```
|
||||
|
||||
**Note**: Create the `/data/jenkins` directory before starting Jenkins.
|
||||
|
||||
### Authentication
|
||||
|
||||
Jenkins API tokens are recommended for authentication. To create an API token:
|
||||
@@ -123,21 +118,36 @@ 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 multi-line `key=value` format (one per line) |
|
||||
| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | No | Allow insecure SSL connections (default: false) |
|
||||
| CA Cert | `--ca-cert` | `PLUGIN_CA_CERT`, `JENKINS_CA_CERT` | No | Custom CA certificate (PEM content, file path, or HTTP URL) |
|
||||
| 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) |
|
||||
| Debug | `--debug` | `PLUGIN_DEBUG`, `JENKINS_DEBUG` | No | Enable debug mode to show detailed parameter information (default: false) |
|
||||
|
||||
**Authentication Requirements**: You must provide either:
|
||||
|
||||
- `user` + `token` (API token authentication), OR
|
||||
- `remote-token` (remote trigger token authentication)
|
||||
|
||||
**Parameters Format**: The `parameters` field accepts a multi-line string where each line contains one `key=value` pair:
|
||||
|
||||
- Each parameter should be on a separate line
|
||||
- Format: `KEY=VALUE` (one per line)
|
||||
- Empty lines are automatically ignored
|
||||
- Whitespace-only lines are skipped
|
||||
- Keys are trimmed of surrounding whitespace
|
||||
- Values preserve intentional spaces
|
||||
- Values can contain `=` signs (everything after the first `=` is treated as the value)
|
||||
|
||||
## Usage
|
||||
|
||||
### Command Line
|
||||
@@ -171,8 +181,21 @@ drone-jenkins \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--parameters "ENVIRONMENT=production" \
|
||||
--parameters "VERSION=1.0.0"
|
||||
--parameters $'ENVIRONMENT=production\nVERSION=1.0.0'
|
||||
```
|
||||
|
||||
Or using environment variable:
|
||||
|
||||
```bash
|
||||
export JENKINS_PARAMETERS="ENVIRONMENT=production
|
||||
VERSION=1.0.0
|
||||
BRANCH=main"
|
||||
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**Using remote token authentication:**
|
||||
@@ -184,6 +207,50 @@ 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
|
||||
```
|
||||
|
||||
**With debug mode:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--debug
|
||||
```
|
||||
|
||||
**With custom CA certificate:**
|
||||
|
||||
```bash
|
||||
# Using a file path
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert /path/to/ca.pem
|
||||
|
||||
# Using a URL
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert https://example.com/ca-bundle.crt
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
**Single job:**
|
||||
@@ -216,7 +283,56 @@ docker run --rm \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_PARAMETERS="ENVIRONMENT=production,VERSION=1.0.0" \
|
||||
-e JENKINS_PARAMETERS=$'ENVIRONMENT=production\nVERSION=1.0.0\nBRANCH=main' \
|
||||
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
|
||||
```
|
||||
|
||||
**With debug mode:**
|
||||
|
||||
```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_DEBUG=true \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**With custom CA certificate:**
|
||||
|
||||
```bash
|
||||
# Using a mounted certificate file
|
||||
docker run --rm \
|
||||
-v /path/to/ca.pem:/ca.pem:ro \
|
||||
-e JENKINS_URL=https://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_CA_CERT=/ca.pem \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
|
||||
# Using a URL
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=https://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_CA_CERT=https://example.com/ca-bundle.crt \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
@@ -253,10 +369,11 @@ steps:
|
||||
job:
|
||||
- deploy-frontend
|
||||
- deploy-backend
|
||||
parameters:
|
||||
- ENVIRONMENT=production
|
||||
- VERSION=${DRONE_TAG}
|
||||
- COMMIT_SHA=${DRONE_COMMIT_SHA}
|
||||
parameters: |
|
||||
ENVIRONMENT=production
|
||||
VERSION=${DRONE_TAG}
|
||||
COMMIT_SHA=${DRONE_COMMIT_SHA}
|
||||
BRANCH=${DRONE_BRANCH}
|
||||
```
|
||||
|
||||
**Using remote token:**
|
||||
@@ -272,6 +389,54 @@ 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
|
||||
```
|
||||
|
||||
**With debug mode:**
|
||||
|
||||
```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: my-jenkins-job
|
||||
debug: true
|
||||
```
|
||||
|
||||
**With custom CA certificate:**
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: trigger-jenkins
|
||||
image: ghcr.io/appleboy/drone-jenkins
|
||||
settings:
|
||||
url: https://jenkins.example.com/
|
||||
user: appleboy
|
||||
token:
|
||||
from_secret: jenkins_token
|
||||
job: my-jenkins-job
|
||||
ca_cert:
|
||||
from_secret: jenkins_ca_cert
|
||||
```
|
||||
|
||||
For more detailed examples and advanced configurations, see [DOCS.md](DOCS.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
module github.com/appleboy/drone-jenkins
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/appleboy/com v1.1.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v2 v2.27.5
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/yassinebenaid/godump v0.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -13,6 +15,6 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/appleboy/com v1.1.1 h1:iqu+BzrEcO3Towwi4E0GDRLSEeMBix3gf3LRjn9h8ow=
|
||||
github.com/appleboy/com v1.1.1/go.mod h1:WKU8+CaWcyLkpm0NLhGA8Wl/yGi3KXfTIXsp7T2ceZc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -10,10 +12,12 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yassinebenaid/godump v0.11.1 h1:SPujx/XaYqGDfmNh7JI3dOyCUVrG0bG2duhO3Eh2EhI=
|
||||
github.com/yassinebenaid/godump v0.11.1/go.mod h1:dc/0w8wmg6kVIvNGAzbKH1Oa54dXQx8SNKh4dPRyW44=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+339
-15
@@ -3,12 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/appleboy/com/gh"
|
||||
"github.com/yassinebenaid/godump"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -24,29 +31,135 @@ type (
|
||||
BaseURL string
|
||||
Token string // Remote trigger token
|
||||
Client *http.Client
|
||||
Debug bool // Enable debug mode to show detailed information
|
||||
}
|
||||
|
||||
// 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
|
||||
func NewJenkins(auth *Auth, url string, token string, insecure bool) *Jenkins {
|
||||
url = strings.TrimRight(url, "/")
|
||||
// loadCACert loads a CA certificate from various sources:
|
||||
// - PEM content (if it starts with "-----BEGIN")
|
||||
// - File path (if the file exists)
|
||||
// - HTTP/HTTPS URL (if it starts with "http://" or "https://")
|
||||
func loadCACert(caCert string) ([]byte, error) {
|
||||
if caCert == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := http.DefaultClient
|
||||
// Check if it's PEM content (starts with BEGIN marker)
|
||||
if strings.HasPrefix(strings.TrimSpace(caCert), "-----BEGIN") {
|
||||
return []byte(caCert), nil
|
||||
}
|
||||
|
||||
// Check if it's an HTTP/HTTPS URL
|
||||
if strings.HasPrefix(caCert, "http://") || strings.HasPrefix(caCert, "https://") {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, caCert, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request for CA certificate URL: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req) // #nosec G107 -- URL is user-provided configuration
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch CA certificate from URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch CA certificate: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CA certificate from URL: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Otherwise, treat it as a file path
|
||||
data, err := os.ReadFile(caCert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CA certificate file: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// NewJenkins is initial Jenkins object
|
||||
func NewJenkins(
|
||||
auth *Auth,
|
||||
baseURL string,
|
||||
token string,
|
||||
insecure bool,
|
||||
caCert string,
|
||||
debug bool,
|
||||
) (*Jenkins, error) {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
// Load CA certificate if provided
|
||||
caCertData, err := loadCACert(caCert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Build TLS configuration
|
||||
var tlsConfig *tls.Config
|
||||
if insecure {
|
||||
// #nosec G402 -- InsecureSkipVerify is intentionally configurable by user
|
||||
tlsConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
} else if caCertData != nil {
|
||||
// Create certificate pool with custom CA
|
||||
certPool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
// Fall back to empty pool if system pool unavailable
|
||||
certPool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
if !certPool.AppendCertsFromPEM(caCertData) {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate")
|
||||
}
|
||||
|
||||
tlsConfig = &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
client := http.DefaultClient
|
||||
if tlsConfig != nil {
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
// #nosec G402 -- InsecureSkipVerify is intentionally configurable by user
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &Jenkins{
|
||||
Auth: auth,
|
||||
BaseURL: url,
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
Client: client,
|
||||
}
|
||||
Debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (jenkins *Jenkins) buildURL(path string, params url.Values) (requestURL string) {
|
||||
@@ -68,17 +181,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 +200,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 +211,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 +283,126 @@ 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,
|
||||
)
|
||||
|
||||
// Debug: Display final build info
|
||||
if jenkins.Debug {
|
||||
log.Println("=== Debug Mode: Build Result ===")
|
||||
if err := godump.Dump(buildInfo); err != nil {
|
||||
log.Printf("warning: failed to dump build info: %v", err)
|
||||
}
|
||||
log.Println("================================")
|
||||
}
|
||||
|
||||
// Set GitHub Actions output
|
||||
if err := gh.SetOutput(map[string]string{
|
||||
"result": buildInfo.Result,
|
||||
"url": buildInfo.URL,
|
||||
}); err != nil {
|
||||
log.Printf("warning: failed to set GitHub output: %v", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -140,6 +427,43 @@ func (jenkins *Jenkins) trigger(job string, params url.Values) error {
|
||||
urlPath = jenkins.parseJobPath(job) + "/build"
|
||||
}
|
||||
|
||||
// Debug: Display parameters being sent
|
||||
if jenkins.Debug {
|
||||
log.Println("=== Debug Mode: Jenkins Job Trigger ===")
|
||||
log.Printf("Job: %s", job)
|
||||
log.Printf("URL Path: %s", urlPath)
|
||||
|
||||
// Build the full URL for display
|
||||
fullURL := jenkins.buildURL(urlPath, params)
|
||||
// Mask token in URL for display
|
||||
if jenkins.Token != "" {
|
||||
fullURL = strings.Replace(fullURL, "token="+jenkins.Token, "token=***MASKED***", 1)
|
||||
}
|
||||
log.Printf("Full URL: %s", fullURL)
|
||||
|
||||
if len(params) > 0 {
|
||||
// Create a copy of params with masked token for display
|
||||
displayParams := url.Values{}
|
||||
for key, values := range params {
|
||||
if key == "token" {
|
||||
// Mask token values for security
|
||||
displayParams[key] = []string{"***MASKED***"}
|
||||
} else {
|
||||
displayParams[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Parameters:")
|
||||
if err := godump.Dump(displayParams); err != nil {
|
||||
log.Printf("warning: failed to dump parameters: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Parameters: (none)")
|
||||
}
|
||||
log.Println("======================================")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
+642
-6
@@ -4,7 +4,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,7 +18,8 @@ func TestParseJobPath(t *testing.T) {
|
||||
Username: "appleboy",
|
||||
Token: "1234",
|
||||
}
|
||||
jenkins := NewJenkins(auth, "http://example.com", "", false)
|
||||
jenkins, err := NewJenkins(auth, "http://example.com", "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/job/foo", jenkins.parseJobPath("/foo/"))
|
||||
assert.Equal(t, "/job/foo", jenkins.parseJobPath("foo/"))
|
||||
@@ -27,10 +32,12 @@ func TestUnSupportProtocol(t *testing.T) {
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
}
|
||||
jenkins := NewJenkins(auth, "example.com", "", false)
|
||||
jenkins, err := NewJenkins(auth, "example.com", "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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 +45,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()
|
||||
|
||||
@@ -46,12 +54,640 @@ func TestTriggerBuild(t *testing.T) {
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
}
|
||||
jenkins := NewJenkins(auth, server.URL, "remote-token", false)
|
||||
jenkins, err := NewJenkins(auth, server.URL, "remote-token", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := NewJenkins(auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// Sample CA certificate for testing (self-signed, not for production use)
|
||||
const testCACert = `-----BEGIN CERTIFICATE-----
|
||||
MIIDAzCCAeugAwIBAgIUGYBGBr+t20UAWJorEPULxzGIXUEwDQYJKoZIhvcNAQEL
|
||||
BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI1MTIwNjA1MDgzMloXDTM1MTIwNDA1
|
||||
MDgzMlowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEAq4bwnABqFenRVUoHLKhPiJXkh6TBFUaCWiEpKYNPywptBJNdyWNf
|
||||
ouDxJ8gvQOMCkp3trnAHFcT6W5s8QLM1Hf/70QZI9GU/BtYm0KijU8aM+GJawNto
|
||||
sK103TeCd0tVenDkxfamBGYnh3L5jtk0V/jeIsAIfFoe9Citu3MttRfxnSmZ4w2K
|
||||
qlS14vKhFlO4WrXAh9j4PaVE5DL7jya/UKe6VVQIONCwUipRN6nU3UXhR7akVSmF
|
||||
/bYkFsfdcErXJHjDpg+0xOsa5LJhzRkx5Uoqtviq2oRVVYhZc0eTwjq/407ocJ25
|
||||
6WmerfKrtFDpzOZPa4XPVX9Am4vWugtrwQIDAQABo1MwUTAdBgNVHQ4EFgQUh7kL
|
||||
LqmsvQP3TI6eiLVK7Gs7A00wHwYDVR0jBBgwFoAUh7kLLqmsvQP3TI6eiLVK7Gs7
|
||||
A00wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEApLOdWacya+Zi
|
||||
w0Fd3UfSveuRsayAkMkZ4p0L9XKlADzwKtSF1Ykn6wiEiYfXd2TvffsR2XglOXFc
|
||||
181IpBhP5u2mzK6pRvH9mqTs3w8JTcXMFmg8AKE2Vg5k22tBM2OUJJgKXkiACuHS
|
||||
pZeOOvJcnjGunbTRwqais0TLYnkOcFsbgrSBKv82HiVootH/iKZahf1ViFMOURTh
|
||||
MqjwIous7Y53Rq4RmfycIjNwODlDW0i5atKe8incDBiIYKw6sH8WN+nuhnHC/vJ5
|
||||
5ZQvGCUsGOvma5ojWAiLs8wu4dODuF5ZNID3t+M36PQs7JDaQNN+AkZROOTSMqa/
|
||||
ud3vS1A5+g==
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
func TestLoadCACert(t *testing.T) {
|
||||
t.Run("empty string returns nil", func(t *testing.T) {
|
||||
data, err := loadCACert("")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, data)
|
||||
})
|
||||
|
||||
t.Run("PEM content directly", func(t *testing.T) {
|
||||
data, err := loadCACert(testCACert)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
assert.Contains(t, string(data), "BEGIN CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("PEM content with leading whitespace", func(t *testing.T) {
|
||||
data, err := loadCACert(" \n" + testCACert)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
assert.Contains(t, string(data), "BEGIN CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("file path", func(t *testing.T) {
|
||||
// Create a temporary file with the certificate
|
||||
tmpDir := t.TempDir()
|
||||
certFile := filepath.Join(tmpDir, "ca.pem")
|
||||
err := os.WriteFile(certFile, []byte(testCACert), 0o600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := loadCACert(certFile)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
assert.Contains(t, string(data), "BEGIN CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
data, err := loadCACert("/nonexistent/path/ca.pem")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, data)
|
||||
assert.Contains(t, err.Error(), "failed to read CA certificate file")
|
||||
})
|
||||
|
||||
t.Run("HTTP URL", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(testCACert))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
data, err := loadCACert(server.URL)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
assert.Contains(t, string(data), "BEGIN CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("HTTPS URL", func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(testCACert))
|
||||
}),
|
||||
)
|
||||
defer server.Close()
|
||||
|
||||
// Note: This test uses the test server's self-signed cert
|
||||
// In real scenarios, the URL would be to a trusted source
|
||||
// We skip HTTPS verification for this test
|
||||
data, err := loadCACert(server.URL)
|
||||
// This may fail due to certificate verification, which is expected
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "certificate")
|
||||
} else {
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP URL returns error status", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
data, err := loadCACert(server.URL)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, data)
|
||||
assert.Contains(t, err.Error(), "HTTP 404")
|
||||
})
|
||||
|
||||
t.Run("HTTP URL unreachable", func(t *testing.T) {
|
||||
data, err := loadCACert("http://localhost:59999/nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, data)
|
||||
assert.Contains(t, err.Error(), "failed to fetch CA certificate from URL")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewJenkinsWithCACert(t *testing.T) {
|
||||
t.Run("with valid CA certificate", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
jenkins, err := NewJenkins(auth, "https://example.com", "", false, testCACert, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, jenkins)
|
||||
assert.NotNil(t, jenkins.Client)
|
||||
})
|
||||
|
||||
t.Run("with CA certificate from file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
certFile := filepath.Join(tmpDir, "ca.pem")
|
||||
err := os.WriteFile(certFile, []byte(testCACert), 0o600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
jenkins, err := NewJenkins(auth, "https://example.com", "", false, certFile, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, jenkins)
|
||||
})
|
||||
|
||||
t.Run("with invalid CA certificate content", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
auth,
|
||||
"https://example.com",
|
||||
"",
|
||||
false,
|
||||
"invalid-cert-data",
|
||||
false,
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, jenkins)
|
||||
assert.Contains(t, err.Error(), "failed to read CA certificate file")
|
||||
})
|
||||
|
||||
t.Run("with invalid PEM format", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
invalidPEM := "-----BEGIN CERTIFICATE-----\ninvalid-base64-data\n-----END CERTIFICATE-----"
|
||||
jenkins, err := NewJenkins(auth, "https://example.com", "", false, invalidPEM, false)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, jenkins)
|
||||
assert.Contains(t, err.Error(), "failed to parse CA certificate")
|
||||
})
|
||||
|
||||
t.Run("with nonexistent file path", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
auth,
|
||||
"https://example.com",
|
||||
"",
|
||||
false,
|
||||
"/nonexistent/ca.pem",
|
||||
false,
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, jenkins)
|
||||
assert.Contains(t, err.Error(), "failed to load CA certificate")
|
||||
})
|
||||
|
||||
t.Run("insecure flag takes precedence over CA cert", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
// When insecure is true, CA cert should be ignored
|
||||
jenkins, err := NewJenkins(auth, "https://example.com", "", true, testCACert, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, jenkins)
|
||||
})
|
||||
|
||||
t.Run("without CA certificate uses default client", func(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "test",
|
||||
Token: "test",
|
||||
}
|
||||
jenkins, err := NewJenkins(auth, "https://example.com", "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, jenkins)
|
||||
assert.Equal(t, http.DefaultClient, jenkins.Client)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/yassinebenaid/godump"
|
||||
)
|
||||
|
||||
// Version set at compile-time
|
||||
@@ -22,6 +24,14 @@ ________ ____. __ .__
|
||||
version: {{.Version}}
|
||||
`
|
||||
|
||||
// maskToken masks a token string for secure display
|
||||
func maskToken(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return "***MASKED***"
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load env-file if it exists first
|
||||
if filename, found := os.LookupEnv("PLUGIN_ENV_FILE"); found {
|
||||
@@ -82,12 +92,43 @@ func main() {
|
||||
Usage: "allow insecure server connections when using SSL",
|
||||
EnvVars: []string{"PLUGIN_INSECURE", "JENKINS_INSECURE", "INPUT_INSECURE"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
&cli.StringFlag{
|
||||
Name: "ca-cert",
|
||||
Usage: "custom CA certificate (PEM content, file path, or HTTP URL)",
|
||||
EnvVars: []string{"PLUGIN_CA_CERT", "JENKINS_CA_CERT", "INPUT_CA_CERT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "parameters",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "jenkins build parameters",
|
||||
Usage: "jenkins build parameters (multi-line format: key=value, one per line)",
|
||||
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"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "enable debug mode to show detailed parameter information",
|
||||
EnvVars: []string{"PLUGIN_DEBUG", "JENKINS_DEBUG", "INPUT_DEBUG"},
|
||||
},
|
||||
}
|
||||
|
||||
// Override a template
|
||||
@@ -142,13 +183,57 @@ 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"),
|
||||
CACert: c.String("ca-cert"),
|
||||
Parameters: c.String("parameters"),
|
||||
Wait: c.Bool("wait"),
|
||||
PollInterval: c.Duration("poll-interval"),
|
||||
Timeout: c.Duration("timeout"),
|
||||
Debug: c.Bool("debug"),
|
||||
}
|
||||
|
||||
// Display plugin configuration in debug mode
|
||||
if plugin.Debug {
|
||||
log.Println("=== Debug Mode: Plugin Configuration ===")
|
||||
|
||||
// Create a display copy with masked sensitive data
|
||||
displayPlugin := struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Token string
|
||||
RemoteToken string
|
||||
Job []string
|
||||
Insecure bool
|
||||
CACert string
|
||||
Parameters string
|
||||
Wait bool
|
||||
PollInterval time.Duration
|
||||
Timeout time.Duration
|
||||
Debug bool
|
||||
}{
|
||||
BaseURL: plugin.BaseURL,
|
||||
Username: plugin.Username,
|
||||
Token: maskToken(plugin.Token),
|
||||
RemoteToken: maskToken(plugin.RemoteToken),
|
||||
Job: plugin.Job,
|
||||
Insecure: plugin.Insecure,
|
||||
CACert: plugin.CACert,
|
||||
Parameters: plugin.Parameters,
|
||||
Wait: plugin.Wait,
|
||||
PollInterval: plugin.PollInterval,
|
||||
Timeout: plugin.Timeout,
|
||||
Debug: plugin.Debug,
|
||||
}
|
||||
|
||||
if err := godump.Dump(displayPlugin); err != nil {
|
||||
log.Printf("warning: failed to dump plugin configuration: %v", err)
|
||||
}
|
||||
log.Println("========================================")
|
||||
}
|
||||
|
||||
return plugin.Exec()
|
||||
|
||||
@@ -6,19 +6,25 @@ 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
|
||||
CACert string // Custom CA certificate (PEM content, file path, or HTTP URL)
|
||||
Parameters string // Job parameters in key=value format (one per line)
|
||||
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)
|
||||
Debug bool // Enable debug mode to show detailed parameter information
|
||||
}
|
||||
)
|
||||
|
||||
@@ -37,15 +43,27 @@ func trimWhitespaceFromSlice(items []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// parseParameters converts a slice of key=value strings into url.Values.
|
||||
// parseParameters converts a multi-line string of key=value pairs into url.Values.
|
||||
// Each line should contain one key=value pair.
|
||||
// It logs a warning for any parameters that don't match the expected format.
|
||||
func parseParameters(params []string) url.Values {
|
||||
func parseParameters(params string) url.Values {
|
||||
values := url.Values{}
|
||||
|
||||
for _, param := range params {
|
||||
parts := strings.SplitN(param, "=", 2)
|
||||
// Split by newlines and process each line
|
||||
lines := strings.Split(params, "\n")
|
||||
for _, line := range lines {
|
||||
// Skip empty lines
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if trimmedLine == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmedLine, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Printf("warning: skipping invalid parameter format (expected key=value): %q", param)
|
||||
log.Printf(
|
||||
"warning: skipping invalid parameter format (expected key=value): %q",
|
||||
trimmedLine,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -53,7 +71,7 @@ func parseParameters(params []string) url.Values {
|
||||
value := parts[1] // Keep value as-is to preserve intentional spaces
|
||||
|
||||
if key == "" {
|
||||
log.Printf("warning: skipping parameter with empty key: %q", param)
|
||||
log.Printf("warning: skipping parameter with empty key: %q", trimmedLine)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -100,17 +118,52 @@ func (p Plugin) Exec() error {
|
||||
}
|
||||
|
||||
// Initialize Jenkins client
|
||||
jenkins := NewJenkins(auth, p.BaseURL, p.RemoteToken, p.Insecure)
|
||||
jenkins, err := NewJenkins(auth, p.BaseURL, p.RemoteToken, p.Insecure, p.CACert, p.Debug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize Jenkins client: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+119
-17
@@ -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 {
|
||||
@@ -115,12 +122,12 @@ func TestTrimWhitespaceFromSlice(t *testing.T) {
|
||||
func TestParseParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
input string
|
||||
expected url.Values
|
||||
}{
|
||||
{
|
||||
name: "valid parameters",
|
||||
input: []string{"key1=value1", "key2=value2"},
|
||||
input: "key1=value1\nkey2=value2",
|
||||
expected: url.Values{
|
||||
"key1": []string{"value1"},
|
||||
"key2": []string{"value2"},
|
||||
@@ -128,38 +135,38 @@ func TestParseParameters(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "parameter with multiple equals signs",
|
||||
input: []string{"key=value=with=equals"},
|
||||
input: "key=value=with=equals",
|
||||
expected: url.Values{
|
||||
"key": []string{"value=with=equals"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parameter with spaces in value",
|
||||
input: []string{"key=value with spaces"},
|
||||
input: "key=value with spaces",
|
||||
expected: url.Values{
|
||||
"key": []string{"value with spaces"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parameter with empty value",
|
||||
input: []string{"key="},
|
||||
input: "key=",
|
||||
expected: url.Values{
|
||||
"key": []string{""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid parameter format (no equals)",
|
||||
input: []string{"invalid"},
|
||||
input: "invalid",
|
||||
expected: url.Values{},
|
||||
},
|
||||
{
|
||||
name: "parameter with empty key",
|
||||
input: []string{"=value"},
|
||||
input: "=value",
|
||||
expected: url.Values{},
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
input: []string{"valid=yes", "invalid", "also=valid"},
|
||||
input: "valid=yes\ninvalid\nalso=valid",
|
||||
expected: url.Values{
|
||||
"valid": []string{"yes"},
|
||||
"also": []string{"valid"},
|
||||
@@ -167,16 +174,32 @@ func TestParseParameters(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "key with surrounding whitespace",
|
||||
input: []string{" key =value"},
|
||||
input: " key =value",
|
||||
expected: url.Values{
|
||||
"key": []string{"value"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
input: []string{},
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: url.Values{},
|
||||
},
|
||||
{
|
||||
name: "multiple empty lines",
|
||||
input: "key1=value1\n\n\nkey2=value2",
|
||||
expected: url.Values{
|
||||
"key1": []string{"value1"},
|
||||
"key2": []string{"value2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lines with whitespace only",
|
||||
input: "key1=value1\n \n\t\nkey2=value2",
|
||||
expected: url.Values{
|
||||
"key1": []string{"value1"},
|
||||
"key2": []string{"value2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -264,8 +287,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 +314,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 +339,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()
|
||||
|
||||
@@ -319,7 +349,7 @@ func TestExecWithParameters(t *testing.T) {
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Job: []string{"parameterized-job"},
|
||||
Parameters: []string{"branch=main", "environment=production"},
|
||||
Parameters: "branch=main\nenvironment=production",
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
@@ -335,7 +365,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 +390,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 +409,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