mirror of
https://github.com/appleboy/drone-jenkins.git
synced 2026-06-04 18:23:57 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1985fadc9 | |||
| 45a9d76c71 | |||
| 5ac640a972 | |||
| 24ef1dc20c | |||
| 2e4860b70c | |||
| c885f9c805 | |||
| 764f7b6bf6 | |||
| c8d19e8231 | |||
| c25c40af3b | |||
| e0116d31de | |||
| c2d73374b4 | |||
| c773b54f0e | |||
| 5d50e1e745 | |||
| f2a83d3d6c | |||
| 351ac33e2d | |||
| 984ca01afc | |||
| d30890d257 | |||
| 3309475595 | |||
| 2b5c542196 | |||
| d8cceb3839 | |||
| 6a8250d7e6 | |||
| 8dfbb92314 | |||
| b0761290c2 | |||
| 02829360ad | |||
| f3a67c62a6 | |||
| 069e6455cc | |||
| eb51e55e81 | |||
| da87ddb86b | |||
| 60874908e6 | |||
| f6e62d9c49 | |||
| cf9b9a0a0d | |||
| 4c54d13899 | |||
| 627e233cc6 |
@@ -38,11 +38,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,4 +51,4 @@ jobs:
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
@@ -10,16 +10,22 @@ on:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1"
|
||||
check-latest: true
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -29,13 +35,13 @@ jobs:
|
||||
make build_linux_arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -43,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
@@ -53,8 +59,33 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Build image for scanning
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: drone-jenkins:scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: "drone-jenkins:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-image-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
exit-code: '1'
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-image-results.sarif"
|
||||
category: "trivy-docker-image"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -13,17 +13,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||
distribution: goreleaser
|
||||
|
||||
@@ -12,13 +12,14 @@ jobs:
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "stable"
|
||||
check-latest: true
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.6
|
||||
version: v2.12
|
||||
args: --verbose
|
||||
|
||||
- uses: hadolint/hadolint-action@v3.3.0
|
||||
@@ -30,7 +31,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
go: ["1.25"]
|
||||
go: ["1.25", "1.26"]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
go-build: ~/.cache/go-build
|
||||
@@ -44,13 +45,14 @@ jobs:
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
check-latest: true
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.go-build }}
|
||||
@@ -63,6 +65,6 @@ jobs:
|
||||
go test -race -cover -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
flags: ${{ matrix.os }},go-${{ matrix.go }}
|
||||
|
||||
+53
-24
@@ -10,47 +10,76 @@ on:
|
||||
schedule:
|
||||
# Run daily at 00:00 UTC
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # Required for uploading SARIF results
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
name: Trivy Security Scan
|
||||
trivy-repo-scan:
|
||||
name: Trivy Repository Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Trivy vulnerability scanner (source code)
|
||||
uses: aquasecurity/trivy-action@0.33.1
|
||||
- name: Run Trivy vulnerability scanner (repo)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
scanners: "vuln,secret,misconfig"
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
severity: "CRITICAL,HIGH,MEDIUM"
|
||||
ignore-unfixed: true
|
||||
output: "trivy-repo-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy results to GitHub Security tab
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
sarif_file: "trivy-repo-results.sarif"
|
||||
|
||||
- name: Run Trivy scanner (table output for logs)
|
||||
uses: aquasecurity/trivy-action@0.33.1
|
||||
trivy-image-scan:
|
||||
name: Trivy Image Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
make build_linux_amd64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: drone-jenkins:scan
|
||||
|
||||
- name: Run Trivy vulnerability scanner (image)
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
image-ref: "drone-jenkins:scan"
|
||||
format: "sarif"
|
||||
output: "trivy-image-results.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy image scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
scanners: "vuln,secret,misconfig"
|
||||
format: "table"
|
||||
severity: "CRITICAL,HIGH,MEDIUM"
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
sarif_file: "trivy-image-results.sarif"
|
||||
category: "trivy-image"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
drone-jenkins is a Drone CI plugin (and standalone CLI tool) for triggering Jenkins jobs. It supports multiple authentication methods, build parameters, and can wait for job completion with configurable polling.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```sh
|
||||
make build # Build binary to bin/drone-jenkins
|
||||
make test # Run tests with coverage
|
||||
make lint # Run golangci-lint
|
||||
make fmt # Format code with golangci-lint
|
||||
make docker # Build Docker image
|
||||
make clean # Clean build artifacts
|
||||
make help # Show all available targets
|
||||
```
|
||||
|
||||
To run a single test:
|
||||
|
||||
```sh
|
||||
go test -v -run TestFunctionName ./...
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The codebase is structured as a simple Go CLI application:
|
||||
|
||||
- **main.go** - CLI entry point using `urfave/cli/v2`. Defines all command-line flags and environment variable mappings. Handles debug mode display with token masking.
|
||||
|
||||
- **plugin.go** - Plugin struct and configuration validation. Contains `Exec()` which orchestrates job triggering. Includes `parseParameters()` for converting multi-line `key=value` strings to URL values.
|
||||
|
||||
- **jenkins.go** - Jenkins HTTP client implementation. Handles:
|
||||
- Authentication (basic auth with API token)
|
||||
- SSL/TLS with custom CA certificates (PEM content, file path, or URL)
|
||||
- Job triggering via `/build` or `/buildWithParameters` endpoints
|
||||
- Queue monitoring and build status polling for wait mode
|
||||
- Nested job path parsing (converts `folder/job` to `/job/folder/job/job`)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Authentication**: Either `user + token` (API token auth) OR `remote-token` (remote trigger token). Validated in `main.go:run()`.
|
||||
|
||||
- **Parameters format**: Multi-line string with one `key=value` per line. Parsed in `plugin.go:parseParameters()`.
|
||||
|
||||
- **Wait mode**: Uses two-phase polling - first waits for queue item to get a build number, then polls build status until completion.
|
||||
|
||||
- **Environment variables**: Each flag accepts multiple env vars (e.g., `PLUGIN_URL`, `JENKINS_URL`, `INPUT_URL`) for compatibility with different CI systems.
|
||||
@@ -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)
|
||||
|
||||
@@ -40,34 +40,55 @@ TAGS ?=
|
||||
LDFLAGS ?= -X 'main.Version=$(VERSION)'
|
||||
|
||||
.PHONY: all
|
||||
all: build
|
||||
all: build ## Build the project (default target)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
test: ## Run tests with coverage
|
||||
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run golangci-lint
|
||||
golangci-lint run -v
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format code with golangci-lint
|
||||
golangci-lint fmt
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES)
|
||||
install: $(GOFILES) ## Install binary to GOPATH/bin
|
||||
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' .
|
||||
|
||||
.PHONY: build
|
||||
build: $(EXECUTABLE)
|
||||
build: $(EXECUTABLE) ## Build binary to bin/
|
||||
|
||||
$(EXECUTABLE): $(GOFILES)
|
||||
$(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o bin/$@ .
|
||||
|
||||
build_linux_amd64:
|
||||
.PHONY: build_linux_amd64
|
||||
build_linux_amd64: ## Build for Linux amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/amd64/$(DEPLOY_IMAGE) .
|
||||
|
||||
build_linux_arm64:
|
||||
.PHONY: build_linux_arm64
|
||||
build_linux_arm64: ## Build for Linux arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/arm64/$(DEPLOY_IMAGE) .
|
||||
|
||||
build_linux_arm:
|
||||
.PHONY: build_linux_arm
|
||||
build_linux_arm: ## Build for Linux arm (armv7)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/arm/$(DEPLOY_IMAGE) .
|
||||
|
||||
clean:
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
$(GO) clean -x -i ./...
|
||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
||||
|
||||
version:
|
||||
.PHONY: help
|
||||
help: ## Print this help message.
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: version
|
||||
version: ## Print current version
|
||||
@echo $(VERSION)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# drone-jenkins
|
||||
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||

|
||||
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
|
||||
@@ -8,11 +10,32 @@
|
||||
[](https://codecov.io/gh/appleboy/drone-jenkins)
|
||||
[](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
|
||||
|
||||
A [Drone](https://github.com/drone/drone) plugin for triggering [Jenkins](https://jenkins.io/) jobs with flexible authentication and parameter support.
|
||||
A CLI tool and CI/CD plugin for triggering [Jenkins](https://jenkins.io/) jobs. Works with [GitHub Actions](https://github.com/features/actions), [GitLab CI](https://docs.gitlab.com/ee/ci/), [Gitea Action](https://docs.gitea.com/usage/actions/overview), and any platform that supports Docker containers or shell commands.
|
||||
|
||||
## Why drone-jenkins?
|
||||
|
||||
In modern enterprise environments, teams often adopt different CI/CD platforms based on their specific needs, project requirements, or historical decisions. It's common to find:
|
||||
|
||||
- **Multiple CI platforms coexisting**: Some teams use Jenkins for its extensive plugin ecosystem, while others prefer GitHub Actions or GitLab CI for their simplicity and container-native approach.
|
||||
- **Legacy systems integration**: Organizations with established Jenkins pipelines need to integrate with newer CI/CD workflows without rewriting everything.
|
||||
- **Cross-team collaboration**: Different departments may standardize on different tools, requiring seamless communication between platforms.
|
||||
|
||||
**drone-jenkins** bridges this gap by allowing CI/CD pipelines to trigger Jenkins jobs as part of their workflow. It works seamlessly with **GitHub Actions**, **GitLab CI**, **Gitea Action**, and any CI platform that supports Docker containers or shell commands.
|
||||
|
||||
This enables:
|
||||
|
||||
- **Unified deployment pipelines**: Trigger existing Jenkins deployment jobs from any CI platform without migration
|
||||
- **Gradual migration**: Teams can incrementally move to modern CI platforms while still leveraging Jenkins jobs
|
||||
- **Best of both worlds**: Use GitHub Actions or GitLab CI for modern containerized builds and Jenkins for specialized tasks with specific plugins
|
||||
- **Centralized orchestration**: Coordinate builds across multiple CI systems from a single pipeline
|
||||
- **Flexibility**: Available as a CLI binary or Docker image—use it however fits your workflow
|
||||
|
||||
Whether you're managing a hybrid CI/CD environment or orchestrating complex multi-platform deployments, drone-jenkins provides the connectivity you need.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [drone-jenkins](#drone-jenkins)
|
||||
- [Why drone-jenkins?](#why-drone-jenkins)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
@@ -23,11 +46,16 @@ A [Drone](https://github.com/drone/drone) plugin for triggering [Jenkins](https:
|
||||
- [Configuration](#configuration)
|
||||
- [Jenkins Server Setup](#jenkins-server-setup)
|
||||
- [Authentication](#authentication)
|
||||
- [Understanding Jenkins Authentication](#understanding-jenkins-authentication)
|
||||
- [CSRF Protection Notice](#csrf-protection-notice)
|
||||
- [Parameters Reference](#parameters-reference)
|
||||
- [Usage](#usage)
|
||||
- [Command Line](#command-line)
|
||||
- [Docker](#docker)
|
||||
- [Drone CI](#drone-ci)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Error: 403 No valid crumb was included in the request](#error-403-no-valid-crumb-was-included-in-the-request)
|
||||
- [Error: 401 Unauthorized](#error-401-unauthorized)
|
||||
- [Remote Token Not Working](#remote-token-not-working)
|
||||
- [Development](#development)
|
||||
- [Building](#building)
|
||||
- [Testing](#testing)
|
||||
@@ -39,9 +67,11 @@ 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
|
||||
- Available as CLI binary or Docker image
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -96,19 +126,25 @@ 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:
|
||||
#### Understanding Jenkins Authentication
|
||||
|
||||
Jenkins supports multiple authentication methods for triggering builds. This tool supports two approaches:
|
||||
|
||||
**1. API Token Authentication (Recommended)**
|
||||
|
||||
Use Jenkins user credentials with an API token. This method:
|
||||
|
||||
- ✅ Works with all Jenkins configurations
|
||||
- ✅ Supports CSRF protection (enabled by default in modern Jenkins)
|
||||
- ✅ Supports wait mode to monitor build completion
|
||||
- ✅ Provides full access to Jenkins API features
|
||||
|
||||
To create an API token:
|
||||
|
||||
1. Log into Jenkins
|
||||
2. Click on your username (top right)
|
||||
@@ -119,24 +155,83 @@ Jenkins API tokens are recommended for authentication. To create an API token:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can use a remote trigger token configured in your Jenkins job settings.
|
||||
**2. Remote Trigger Token Authentication**
|
||||
|
||||
Use a remote trigger token configured in your Jenkins job. **Important limitations**:
|
||||
|
||||
- ⚠️ **Does not work** with Jenkins CSRF protection enabled (default in modern Jenkins)
|
||||
- ⚠️ Requires anonymous users to have read access to the job, OR
|
||||
- ⚠️ Must be combined with API token authentication (see Combined Authentication below)
|
||||
|
||||
**3. Combined Authentication (Recommended for Remote Tokens)**
|
||||
|
||||
Use both API token and remote trigger token together:
|
||||
|
||||
- ✅ Works with CSRF protection enabled
|
||||
- ✅ Provides double authentication security
|
||||
- ✅ Supports all features including wait mode
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user YOUR_USERNAME \
|
||||
--token YOUR_API_TOKEN \
|
||||
--remote-token YOUR_REMOTE_TOKEN \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
#### CSRF Protection Notice
|
||||
|
||||
Modern Jenkins installations have CSRF protection enabled by default. If you encounter errors like:
|
||||
|
||||
```txt
|
||||
Error 403 No valid crumb was included in the request
|
||||
```
|
||||
|
||||
This means your Jenkins has CSRF protection enabled. You **must** use API token authentication (option 1 or 3 above). Remote trigger token alone will not work.
|
||||
|
||||
For more information about Jenkins CSRF protection, see the [official Jenkins documentation](https://www.jenkins.io/doc/book/security/csrf-protection/).
|
||||
|
||||
### 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:
|
||||
**Authentication Requirements**:
|
||||
|
||||
- `user` + `token` (API token authentication), OR
|
||||
- `remote-token` (remote trigger token authentication)
|
||||
For Jenkins with **CSRF protection enabled** (default in modern Jenkins):
|
||||
|
||||
- **Required**: `user` + `token` (API token authentication)
|
||||
- **Optional**: `remote-token` (for additional security)
|
||||
|
||||
For Jenkins with **CSRF protection disabled** (not recommended):
|
||||
|
||||
- **Option 1**: `user` + `token` (API token authentication)
|
||||
- **Option 2**: `remote-token` only (requires anonymous read access to job)
|
||||
|
||||
**Important**: If you encounter "403 No valid crumb" errors, you must use API token authentication (`user` + `token`).
|
||||
|
||||
**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
|
||||
|
||||
@@ -171,19 +266,89 @@ 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'
|
||||
```
|
||||
|
||||
**Using remote token authentication:**
|
||||
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 combined authentication (API token + remote token - Recommended):**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--remote-token REMOTE_TOKEN_HERE \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**Using remote token only (only works without CSRF protection):**
|
||||
|
||||
```bash
|
||||
# Note: This will fail if Jenkins has CSRF protection enabled
|
||||
# You will get "403 No valid crumb" error
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--remote-token REMOTE_TOKEN_HERE \
|
||||
--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,64 +381,124 @@ 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
|
||||
```
|
||||
|
||||
### Drone CI
|
||||
**With combined authentication (API token + remote token):**
|
||||
|
||||
Add the plugin to your `.drone.yml`:
|
||||
|
||||
```yaml
|
||||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: trigger-jenkins
|
||||
image: ghcr.io/appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://jenkins.example.com/
|
||||
user: appleboy
|
||||
token:
|
||||
from_secret: jenkins_token
|
||||
job: drone-jenkins-plugin
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_REMOTE_TOKEN=your_remote_token \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**Multiple jobs with parameters:**
|
||||
**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-frontend
|
||||
- deploy-backend
|
||||
parameters:
|
||||
- ENVIRONMENT=production
|
||||
- VERSION=${DRONE_TAG}
|
||||
- COMMIT_SHA=${DRONE_COMMIT_SHA}
|
||||
```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
|
||||
```
|
||||
|
||||
**Using remote token:**
|
||||
**With debug mode:**
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: trigger-jenkins
|
||||
image: ghcr.io/appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://jenkins.example.com/
|
||||
remote_token:
|
||||
from_secret: jenkins_remote_token
|
||||
job: my-jenkins-job
|
||||
```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
|
||||
```
|
||||
|
||||
For more detailed examples and advanced configurations, see [DOCS.md](DOCS.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: 403 No valid crumb was included in the request
|
||||
|
||||
**Cause**: Your Jenkins server has CSRF protection enabled (this is the default in modern Jenkins). Learn more at the [Jenkins CSRF Protection documentation](https://www.jenkins.io/doc/book/security/csrf-protection/).
|
||||
|
||||
**Solution**: Use API token authentication instead of remote token only:
|
||||
|
||||
```bash
|
||||
# ❌ This will fail with CSRF protection enabled
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--remote-token YOUR_REMOTE_TOKEN \
|
||||
--job my-jenkins-job
|
||||
|
||||
# ✅ Use this instead
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user YOUR_USERNAME \
|
||||
--token YOUR_API_TOKEN \
|
||||
--job my-jenkins-job
|
||||
|
||||
# ✅ Or combine both for additional security
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user YOUR_USERNAME \
|
||||
--token YOUR_API_TOKEN \
|
||||
--remote-token YOUR_REMOTE_TOKEN \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
### Error: 401 Unauthorized
|
||||
|
||||
**Cause**: Invalid credentials or incorrect authentication method.
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify your username and API token are correct
|
||||
2. Ensure you're using an API token, not your Jenkins password
|
||||
3. Check if you have permission to trigger the job
|
||||
4. Make sure both `--user` and `--token` are provided together
|
||||
|
||||
### Remote Token Not Working
|
||||
|
||||
**Cause**: Remote trigger tokens alone only work in specific scenarios:
|
||||
|
||||
- Jenkins has CSRF protection disabled (not recommended), AND
|
||||
- Anonymous users have read access to the job
|
||||
|
||||
**Solution**: Use combined authentication (API token + remote token) as shown in the examples above.
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# drone-jenkins
|
||||
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||

|
||||
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
|
||||
[](https://godoc.org/github.com/appleboy/drone-jenkins)
|
||||
[](https://codecov.io/gh/appleboy/drone-jenkins)
|
||||
[](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
|
||||
|
||||
一个用于触发 [Jenkins](https://jenkins.io/) 任务的 CLI 工具与 CI/CD 插件。支持 [GitHub Actions](https://github.com/features/actions)、[GitLab CI](https://docs.gitlab.com/ee/ci/)、[Gitea Action](https://docs.gitea.com/usage/actions/overview) 以及任何支持 Docker 容器或 Shell 命令的平台。
|
||||
|
||||
## 为什么选择 drone-jenkins?
|
||||
|
||||
在现代企业环境中,团队经常根据特定需求、项目要求或历史决策采用不同的 CI/CD 平台。常见的情况包括:
|
||||
|
||||
- **多个 CI 平台并存**:有些团队因为 Jenkins 丰富的插件生态系统而使用它,而其他团队则偏好 GitHub Actions 或 GitLab CI 的简洁性和容器原生方式。
|
||||
- **遗留系统集成**:拥有既有 Jenkins 流水线的组织需要与新的 CI/CD 工作流程集成,而不需要重写所有内容。
|
||||
- **跨团队协作**:不同部门可能标准化使用不同的工具,需要平台之间的无缝沟通。
|
||||
|
||||
**drone-jenkins** 弥补了这个差距,让 CI/CD 流水线能够将触发 Jenkins 任务作为工作流程的一部分。它可以与 **GitHub Actions**、**GitLab CI**、**Gitea Action** 以及任何支持 Docker 容器或 Shell 命令的 CI 平台无缝协作。
|
||||
|
||||
这使得以下场景成为可能:
|
||||
|
||||
- **统一的部署流水线**:从任何 CI 平台触发现有的 Jenkins 部署任务,无需迁移
|
||||
- **渐进式迁移**:团队可以逐步迁移到现代 CI 平台,同时继续使用 Jenkins 任务
|
||||
- **两全其美**:使用 GitHub Actions 或 GitLab CI 进行现代容器化构建,并使用 Jenkins 处理需要特定插件的专门任务
|
||||
- **集中式编排**:从单一流水线协调跨多个 CI 系统的构建
|
||||
- **灵活使用**:提供 CLI 可执行文件或 Docker 镜像——根据您的工作流程选择使用方式
|
||||
|
||||
无论您是在管理混合 CI/CD 环境还是编排复杂的多平台部署,drone-jenkins 都能提供您所需的连接能力。
|
||||
|
||||
## 目录
|
||||
|
||||
- [drone-jenkins](#drone-jenkins)
|
||||
- [为什么选择 drone-jenkins?](#为什么选择-drone-jenkins)
|
||||
- [目录](#目录)
|
||||
- [功能特性](#功能特性)
|
||||
- [前置条件](#前置条件)
|
||||
- [安装](#安装)
|
||||
- [下载可执行文件](#下载可执行文件)
|
||||
- [从源码构建](#从源码构建)
|
||||
- [Docker 镜像](#docker-镜像)
|
||||
- [配置](#配置)
|
||||
- [Jenkins 服务器设置](#jenkins-服务器设置)
|
||||
- [认证](#认证)
|
||||
- [CSRF 保护注意事项](#csrf-保护注意事项)
|
||||
- [参数参考](#参数参考)
|
||||
- [使用方式](#使用方式)
|
||||
- [命令行](#命令行)
|
||||
- [Docker](#docker)
|
||||
- [开发](#开发)
|
||||
- [构建](#构建)
|
||||
- [测试](#测试)
|
||||
- [许可证](#许可证)
|
||||
- [贡献](#贡献)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 触发单个或多个 Jenkins 任务
|
||||
- 支持 Jenkins 构建参数
|
||||
- 多种认证方式(API 令牌或远程触发令牌)
|
||||
- 等待任务完成,可配置轮询间隔和超时时间
|
||||
- 调试模式,显示详细参数信息并安全遮蔽令牌
|
||||
- SSL/TLS 支持,可使用自定义 CA 证书(PEM 内容、文件路径或 URL)
|
||||
- 跨平台支持(Linux、macOS、Windows)
|
||||
- 提供 CLI 可执行文件或 Docker 镜像
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Jenkins 服务器(建议版本 2.0 或更新版本)
|
||||
- 用于认证的 Jenkins API 令牌或远程触发令牌
|
||||
- 对于 Jenkins 设置,建议使用 Docker,但非必需
|
||||
|
||||
## 安装
|
||||
|
||||
### 下载可执行文件
|
||||
|
||||
预编译的可执行文件可从[发布页面](https://github.com/appleboy/drone-jenkins/releases)下载,支持:
|
||||
|
||||
- **Linux**: amd64, 386
|
||||
- **macOS (Darwin)**: amd64, 386
|
||||
- **Windows**: amd64, 386
|
||||
|
||||
如果已安装 Go,也可以直接安装:
|
||||
|
||||
```sh
|
||||
go install github.com/appleboy/drone-jenkins@latest
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
克隆仓库并构建:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/appleboy/drone-jenkins.git
|
||||
cd drone-jenkins
|
||||
make build
|
||||
```
|
||||
|
||||
### Docker 镜像
|
||||
|
||||
构建 Docker 镜像:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
或拉取预构建的镜像:
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### Jenkins 服务器设置
|
||||
|
||||
使用 Docker 设置 Jenkins 服务器:
|
||||
|
||||
```sh
|
||||
docker run -d -v jenkins_home:/var/jenkins_home -p 8080:8080 -p 50000:50000 --restart=on-failure jenkins/jenkins:slim
|
||||
```
|
||||
|
||||
### 认证
|
||||
|
||||
建议使用 Jenkins API 令牌进行认证。创建 API 令牌的步骤:
|
||||
|
||||
1. 登录 Jenkins
|
||||
2. 点击右上角的用户名
|
||||
3. 选择"安全"
|
||||
4. 在"API 令牌"下,点击"添加新令牌"
|
||||
5. 输入名称并点击"生成"
|
||||
6. 复制生成的令牌
|
||||
|
||||

|
||||
|
||||
或者,您可以使用在 Jenkins 任务设置中配置的远程触发令牌。
|
||||
|
||||
#### CSRF 保护注意事项
|
||||
|
||||
现代 Jenkins 安装默认启用 CSRF 保护。如果您遇到以下错误:
|
||||
|
||||
```
|
||||
Error 403 No valid crumb was included in the request
|
||||
```
|
||||
|
||||
这表示您的 Jenkins 已启用 CSRF 保护。您**必须**使用 API 令牌认证(user + token)。单独使用远程触发令牌将无法工作。
|
||||
|
||||
如需更多关于 Jenkins CSRF 保护的信息,请参阅 [Jenkins 官方文档](https://www.jenkins.io/doc/book/security/csrf-protection/)。
|
||||
|
||||
### 参数参考
|
||||
|
||||
| 参数 | CLI 标志 | 环境变量 | 必需 | 说明 |
|
||||
| ------------- | -------------------- | ----------------------------------------------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| Host | `--host` | `PLUGIN_URL`, `JENKINS_URL` | 是 | Jenkins 基础 URL(例如 `http://jenkins.example.com/`) |
|
||||
| User | `--user`, `-u` | `PLUGIN_USER`, `JENKINS_USER` | 条件式\* | Jenkins 用户名 |
|
||||
| Token | `--token`, `-t` | `PLUGIN_TOKEN`, `JENKINS_TOKEN` | 条件式\* | Jenkins API 令牌 |
|
||||
| Remote Token | `--remote-token` | `PLUGIN_REMOTE_TOKEN`, `JENKINS_REMOTE_TOKEN` | 条件式\* | Jenkins 远程触发令牌 |
|
||||
| Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | 是 | Jenkins 任务名称 - 可指定多个 |
|
||||
| Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | 否 | 构建参数,多行 `key=value` 格式(每行一个) |
|
||||
| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | 否 | 允许不安全的 SSL 连接(默认:false) |
|
||||
| CA Cert | `--ca-cert` | `PLUGIN_CA_CERT`, `JENKINS_CA_CERT` | 否 | 自定义 CA 证书(PEM 内容、文件路径或 HTTP URL) |
|
||||
| Wait | `--wait` | `PLUGIN_WAIT`, `JENKINS_WAIT` | 否 | 等待任务完成(默认:false) |
|
||||
| Poll Interval | `--poll-interval` | `PLUGIN_POLL_INTERVAL`, `JENKINS_POLL_INTERVAL` | 否 | 状态检查间隔(默认:10s) |
|
||||
| Timeout | `--timeout` | `PLUGIN_TIMEOUT`, `JENKINS_TIMEOUT` | 否 | 等待任务完成的最长时间(默认:30m) |
|
||||
| Debug | `--debug` | `PLUGIN_DEBUG`, `JENKINS_DEBUG` | 否 | 启用调试模式以显示详细参数信息(默认:false) |
|
||||
|
||||
**认证要求**:您必须提供以下其中一种:
|
||||
|
||||
- `user` + `token`(API 令牌认证),或
|
||||
- `remote-token`(远程触发令牌认证)
|
||||
|
||||
**参数格式**:`parameters` 字段接受多行字符串,每行包含一个 `key=value` 配对:
|
||||
|
||||
- 每个参数应该在单独一行
|
||||
- 格式:`KEY=VALUE`(每行一个)
|
||||
- 空行会自动忽略
|
||||
- 只有空白的行会被跳过
|
||||
- 键名会去除前后空白
|
||||
- 值会保留有意义的空格
|
||||
- 值可以包含 `=` 符号(第一个 `=` 之后的所有内容都视为值)
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 命令行
|
||||
|
||||
**单个任务:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin
|
||||
```
|
||||
|
||||
**多个任务:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin-1 \
|
||||
--job drone-jenkins-plugin-2
|
||||
```
|
||||
|
||||
**带构建参数:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--parameters $'ENVIRONMENT=production\nVERSION=1.0.0'
|
||||
```
|
||||
|
||||
或使用环境变量:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用远程令牌认证:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--remote-token REMOTE_TOKEN_HERE \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**等待任务完成:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--wait \
|
||||
--poll-interval 15s \
|
||||
--timeout 1h
|
||||
```
|
||||
|
||||
**使用调试模式:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--debug
|
||||
```
|
||||
|
||||
**使用自定义 CA 证书:**
|
||||
|
||||
```bash
|
||||
# 使用文件路径
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert /path/to/ca.pem
|
||||
|
||||
# 使用 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
|
||||
|
||||
**单个任务:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**多个任务:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin-1,drone-jenkins-plugin-2 \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**带构建参数:**
|
||||
|
||||
```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_PARAMETERS=$'ENVIRONMENT=production\nVERSION=1.0.0\nBRANCH=main' \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**等待任务完成:**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用调试模式:**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用自定义 CA 证书:**
|
||||
|
||||
```bash
|
||||
# 使用挂载的证书文件
|
||||
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
|
||||
|
||||
# 使用 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
|
||||
```
|
||||
|
||||
更多详细示例和高级配置,请参阅 [DOCS.md](DOCS.md)。
|
||||
|
||||
## 开发
|
||||
|
||||
### 构建
|
||||
|
||||
构建可执行文件:
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
构建 Docker 镜像:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
运行测试套件:
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
运行测试并生成覆盖率报告:
|
||||
|
||||
```sh
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
Copyright (c) 2019 Bo-Yi Wu
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!请随时提交 Pull Request。
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# drone-jenkins
|
||||
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||

|
||||
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
|
||||
[](https://godoc.org/github.com/appleboy/drone-jenkins)
|
||||
[](https://codecov.io/gh/appleboy/drone-jenkins)
|
||||
[](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
|
||||
|
||||
一個用於觸發 [Jenkins](https://jenkins.io/) 任務的 CLI 工具與 CI/CD 外掛。支援 [GitHub Actions](https://github.com/features/actions)、[GitLab CI](https://docs.gitlab.com/ee/ci/)、[Gitea Action](https://docs.gitea.com/usage/actions/overview) 以及任何支援 Docker 容器或 Shell 命令的平台。
|
||||
|
||||
## 為什麼選擇 drone-jenkins?
|
||||
|
||||
在現代企業環境中,團隊經常根據特定需求、專案要求或歷史決策採用不同的 CI/CD 平台。常見的情況包括:
|
||||
|
||||
- **多個 CI 平台並存**:有些團隊因為 Jenkins 豐富的外掛生態系統而使用它,而其他團隊則偏好 GitHub Actions 或 GitLab CI 的簡潔性和容器原生方式。
|
||||
- **舊有系統整合**:擁有既有 Jenkins 流水線的組織需要與新的 CI/CD 工作流程整合,而不需要重寫所有內容。
|
||||
- **跨團隊協作**:不同部門可能標準化使用不同的工具,需要平台之間的無縫溝通。
|
||||
|
||||
**drone-jenkins** 彌補了這個差距,讓 CI/CD 流水線能夠將觸發 Jenkins 任務作為工作流程的一部分。它可以與 **GitHub Actions**、**GitLab CI**、**Gitea Action** 以及任何支援 Docker 容器或 Shell 命令的 CI 平台無縫協作。
|
||||
|
||||
這使得以下情境成為可能:
|
||||
|
||||
- **統一的部署流水線**:從任何 CI 平台觸發現有的 Jenkins 部署任務,無需遷移
|
||||
- **漸進式遷移**:團隊可以逐步遷移到現代 CI 平台,同時繼續使用 Jenkins 任務
|
||||
- **兩全其美**:使用 GitHub Actions 或 GitLab CI 進行現代容器化建置,並使用 Jenkins 處理需要特定外掛的專門任務
|
||||
- **集中式協調**:從單一流水線協調跨多個 CI 系統的建置
|
||||
- **彈性使用**:提供 CLI 執行檔或 Docker 映像檔——依照您的工作流程選擇使用方式
|
||||
|
||||
無論您是在管理混合 CI/CD 環境還是協調複雜的多平台部署,drone-jenkins 都能提供您所需的連接能力。
|
||||
|
||||
## 目錄
|
||||
|
||||
- [drone-jenkins](#drone-jenkins)
|
||||
- [為什麼選擇 drone-jenkins?](#為什麼選擇-drone-jenkins)
|
||||
- [目錄](#目錄)
|
||||
- [功能特色](#功能特色)
|
||||
- [先決條件](#先決條件)
|
||||
- [安裝](#安裝)
|
||||
- [下載執行檔](#下載執行檔)
|
||||
- [從原始碼建置](#從原始碼建置)
|
||||
- [Docker 映像檔](#docker-映像檔)
|
||||
- [設定](#設定)
|
||||
- [Jenkins 伺服器設定](#jenkins-伺服器設定)
|
||||
- [認證](#認證)
|
||||
- [CSRF 保護注意事項](#csrf-保護注意事項)
|
||||
- [參數參考](#參數參考)
|
||||
- [使用方式](#使用方式)
|
||||
- [命令列](#命令列)
|
||||
- [Docker](#docker)
|
||||
- [開發](#開發)
|
||||
- [建置](#建置)
|
||||
- [測試](#測試)
|
||||
- [授權條款](#授權條款)
|
||||
- [貢獻](#貢獻)
|
||||
|
||||
## 功能特色
|
||||
|
||||
- 觸發單一或多個 Jenkins 任務
|
||||
- 支援 Jenkins 建置參數
|
||||
- 多種認證方式(API 令牌或遠端觸發令牌)
|
||||
- 等待任務完成,可設定輪詢間隔和逾時時間
|
||||
- 除錯模式,顯示詳細參數資訊並安全遮蔽令牌
|
||||
- SSL/TLS 支援,可使用自訂 CA 憑證(PEM 內容、檔案路徑或 URL)
|
||||
- 跨平台支援(Linux、macOS、Windows)
|
||||
- 提供 CLI 執行檔或 Docker 映像檔
|
||||
|
||||
## 先決條件
|
||||
|
||||
- Jenkins 伺服器(建議版本 2.0 或更新版本)
|
||||
- 用於認證的 Jenkins API 令牌或遠端觸發令牌
|
||||
- 對於 Jenkins 設定,建議使用 Docker,但非必要
|
||||
|
||||
## 安裝
|
||||
|
||||
### 下載執行檔
|
||||
|
||||
預先編譯的執行檔可從[發布頁面](https://github.com/appleboy/drone-jenkins/releases)下載,支援:
|
||||
|
||||
- **Linux**: amd64, 386
|
||||
- **macOS (Darwin)**: amd64, 386
|
||||
- **Windows**: amd64, 386
|
||||
|
||||
如果已安裝 Go,也可以直接安裝:
|
||||
|
||||
```sh
|
||||
go install github.com/appleboy/drone-jenkins@latest
|
||||
```
|
||||
|
||||
### 從原始碼建置
|
||||
|
||||
複製儲存庫並建置:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/appleboy/drone-jenkins.git
|
||||
cd drone-jenkins
|
||||
make build
|
||||
```
|
||||
|
||||
### Docker 映像檔
|
||||
|
||||
建置 Docker 映像檔:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
或拉取預先建置的映像檔:
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
## 設定
|
||||
|
||||
### Jenkins 伺服器設定
|
||||
|
||||
使用 Docker 設定 Jenkins 伺服器:
|
||||
|
||||
```sh
|
||||
docker run -d -v jenkins_home:/var/jenkins_home -p 8080:8080 -p 50000:50000 --restart=on-failure jenkins/jenkins:slim
|
||||
```
|
||||
|
||||
### 認證
|
||||
|
||||
建議使用 Jenkins API 令牌進行認證。建立 API 令牌的步驟:
|
||||
|
||||
1. 登入 Jenkins
|
||||
2. 點擊右上角的使用者名稱
|
||||
3. 選擇「安全性」
|
||||
4. 在「API 令牌」下,點擊「新增令牌」
|
||||
5. 輸入名稱並點擊「產生」
|
||||
6. 複製產生的令牌
|
||||
|
||||

|
||||
|
||||
或者,您可以使用在 Jenkins 任務設定中配置的遠端觸發令牌。
|
||||
|
||||
#### CSRF 保護注意事項
|
||||
|
||||
現代 Jenkins 安裝預設啟用 CSRF 保護。如果您遇到以下錯誤:
|
||||
|
||||
```
|
||||
Error 403 No valid crumb was included in the request
|
||||
```
|
||||
|
||||
這表示您的 Jenkins 已啟用 CSRF 保護。您**必須**使用 API 令牌認證(user + token)。單獨使用遠端觸發令牌將無法運作。
|
||||
|
||||
如需更多關於 Jenkins CSRF 保護的資訊,請參閱 [Jenkins 官方文件](https://www.jenkins.io/doc/book/security/csrf-protection/)。
|
||||
|
||||
### 參數參考
|
||||
|
||||
| 參數 | CLI 旗標 | 環境變數 | 必要 | 說明 |
|
||||
| ------------- | -------------------- | ----------------------------------------------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| Host | `--host` | `PLUGIN_URL`, `JENKINS_URL` | 是 | Jenkins 基礎 URL(例如 `http://jenkins.example.com/`) |
|
||||
| User | `--user`, `-u` | `PLUGIN_USER`, `JENKINS_USER` | 條件式\* | Jenkins 使用者名稱 |
|
||||
| Token | `--token`, `-t` | `PLUGIN_TOKEN`, `JENKINS_TOKEN` | 條件式\* | Jenkins API 令牌 |
|
||||
| Remote Token | `--remote-token` | `PLUGIN_REMOTE_TOKEN`, `JENKINS_REMOTE_TOKEN` | 條件式\* | Jenkins 遠端觸發令牌 |
|
||||
| Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | 是 | Jenkins 任務名稱 - 可指定多個 |
|
||||
| Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | 否 | 建置參數,多行 `key=value` 格式(每行一個) |
|
||||
| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | 否 | 允許不安全的 SSL 連線(預設:false) |
|
||||
| CA Cert | `--ca-cert` | `PLUGIN_CA_CERT`, `JENKINS_CA_CERT` | 否 | 自訂 CA 憑證(PEM 內容、檔案路徑或 HTTP URL) |
|
||||
| Wait | `--wait` | `PLUGIN_WAIT`, `JENKINS_WAIT` | 否 | 等待任務完成(預設:false) |
|
||||
| Poll Interval | `--poll-interval` | `PLUGIN_POLL_INTERVAL`, `JENKINS_POLL_INTERVAL` | 否 | 狀態檢查間隔(預設:10s) |
|
||||
| Timeout | `--timeout` | `PLUGIN_TIMEOUT`, `JENKINS_TIMEOUT` | 否 | 等待任務完成的最長時間(預設:30m) |
|
||||
| Debug | `--debug` | `PLUGIN_DEBUG`, `JENKINS_DEBUG` | 否 | 啟用除錯模式以顯示詳細參數資訊(預設:false) |
|
||||
|
||||
**認證要求**:您必須提供以下其中一種:
|
||||
|
||||
- `user` + `token`(API 令牌認證),或
|
||||
- `remote-token`(遠端觸發令牌認證)
|
||||
|
||||
**參數格式**:`parameters` 欄位接受多行字串,每行包含一個 `key=value` 配對:
|
||||
|
||||
- 每個參數應該在單獨一行
|
||||
- 格式:`KEY=VALUE`(每行一個)
|
||||
- 空行會自動忽略
|
||||
- 只有空白的行會被跳過
|
||||
- 鍵名會去除前後空白
|
||||
- 值會保留有意義的空格
|
||||
- 值可以包含 `=` 符號(第一個 `=` 之後的所有內容都視為值)
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 命令列
|
||||
|
||||
**單一任務:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin
|
||||
```
|
||||
|
||||
**多個任務:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin-1 \
|
||||
--job drone-jenkins-plugin-2
|
||||
```
|
||||
|
||||
**帶建置參數:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--parameters $'ENVIRONMENT=production\nVERSION=1.0.0'
|
||||
```
|
||||
|
||||
或使用環境變數:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用遠端令牌認證:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--remote-token REMOTE_TOKEN_HERE \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**等待任務完成:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--wait \
|
||||
--poll-interval 15s \
|
||||
--timeout 1h
|
||||
```
|
||||
|
||||
**使用除錯模式:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--debug
|
||||
```
|
||||
|
||||
**使用自訂 CA 憑證:**
|
||||
|
||||
```bash
|
||||
# 使用檔案路徑
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert /path/to/ca.pem
|
||||
|
||||
# 使用 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
|
||||
|
||||
**單一任務:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**多個任務:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin-1,drone-jenkins-plugin-2 \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**帶建置參數:**
|
||||
|
||||
```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_PARAMETERS=$'ENVIRONMENT=production\nVERSION=1.0.0\nBRANCH=main' \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**等待任務完成:**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用除錯模式:**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**使用自訂 CA 憑證:**
|
||||
|
||||
```bash
|
||||
# 使用掛載的憑證檔案
|
||||
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
|
||||
|
||||
# 使用 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
|
||||
```
|
||||
|
||||
更多詳細範例和進階設定,請參閱 [DOCS.md](DOCS.md)。
|
||||
|
||||
## 開發
|
||||
|
||||
### 建置
|
||||
|
||||
建置執行檔:
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
建置 Docker 映像檔:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
### 測試
|
||||
|
||||
執行測試套件:
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
執行測試並產生覆蓋率報告:
|
||||
|
||||
```sh
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
## 授權條款
|
||||
|
||||
Copyright (c) 2019 Bo-Yi Wu
|
||||
|
||||
## 貢獻
|
||||
|
||||
歡迎貢獻!請隨時提交 Pull Request。
|
||||
@@ -1,11 +1,13 @@
|
||||
module github.com/appleboy/drone-jenkins
|
||||
|
||||
go 1.22
|
||||
go 1.25.10
|
||||
|
||||
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=
|
||||
|
||||
+429
-24
@@ -3,14 +3,24 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/appleboy/com/gh"
|
||||
"github.com/yassinebenaid/godump"
|
||||
)
|
||||
|
||||
const tokenParam = "token"
|
||||
|
||||
type (
|
||||
// Auth contain username and token
|
||||
Auth struct {
|
||||
@@ -24,29 +34,150 @@ type (
|
||||
BaseURL string
|
||||
Token string // Remote trigger token
|
||||
Client *http.Client
|
||||
Debug bool // Enable debug mode to show detailed information
|
||||
crumb *CrumbResponse // Cached CSRF crumb
|
||||
}
|
||||
|
||||
// CrumbResponse represents Jenkins crumb issuer response for CSRF protection
|
||||
CrumbResponse struct {
|
||||
Crumb string `json:"crumb"`
|
||||
CrumbRequestField string `json:"crumbRequestField"`
|
||||
}
|
||||
|
||||
// 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(ctx context.Context, caCert string) ([]byte, error) {
|
||||
if caCert == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := http.DefaultClient
|
||||
if insecure {
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
// #nosec G402 -- InsecureSkipVerify is intentionally configurable by user
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
// 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(ctx, 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(
|
||||
ctx context.Context,
|
||||
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(ctx, 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 CookieJar for session management (required for CSRF crumb)
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP Transport with optional TLS configuration
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
|
||||
// Create HTTP client with CookieJar and Transport
|
||||
client := &http.Client{
|
||||
Jar: jar,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -61,24 +192,68 @@ func (jenkins *Jenkins) buildURL(path string, params url.Values) (requestURL str
|
||||
return
|
||||
}
|
||||
|
||||
func (jenkins *Jenkins) sendRequest(req *http.Request) (*http.Response, error) {
|
||||
if jenkins.Auth != nil {
|
||||
// getCrumb fetches CSRF crumb from Jenkins
|
||||
// Returns nil if CSRF protection is disabled on Jenkins
|
||||
//
|
||||
//nolint:unparam // Error return kept for future extensibility and API consistency
|
||||
func (jenkins *Jenkins) getCrumb(ctx context.Context) (*CrumbResponse, error) {
|
||||
// Return cached crumb if available
|
||||
if jenkins.crumb != nil {
|
||||
return jenkins.crumb, nil
|
||||
}
|
||||
|
||||
path := "/crumbIssuer/api/json"
|
||||
var crumb CrumbResponse
|
||||
err := jenkins.get(ctx, path, nil, &crumb)
|
||||
if err != nil {
|
||||
// CSRF protection might be disabled, log and continue
|
||||
if jenkins.Debug {
|
||||
log.Printf("crumb not available (CSRF may be disabled): %v", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Cache the crumb for subsequent requests
|
||||
jenkins.crumb = &crumb
|
||||
if jenkins.Debug {
|
||||
log.Printf("obtained crumb: %s=%s", crumb.CrumbRequestField, crumb.Crumb)
|
||||
}
|
||||
|
||||
return jenkins.crumb, nil
|
||||
}
|
||||
|
||||
func (jenkins *Jenkins) sendRequest(
|
||||
req *http.Request,
|
||||
crumb *CrumbResponse,
|
||||
) (*http.Response, error) {
|
||||
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
|
||||
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
|
||||
}
|
||||
|
||||
// Add CSRF crumb header if available
|
||||
if crumb != nil && crumb.CrumbRequestField != "" {
|
||||
req.Header.Set(crumb.CrumbRequestField, crumb.Crumb)
|
||||
}
|
||||
|
||||
return jenkins.Client.Do(req)
|
||||
}
|
||||
|
||||
func (jenkins *Jenkins) post(path string, params url.Values, body interface{}) (err error) {
|
||||
func (jenkins *Jenkins) get(
|
||||
ctx context.Context,
|
||||
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(ctx, "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := jenkins.sendRequest(req)
|
||||
resp, err := jenkins.sendRequest(req, nil) // GET requests don't need crumb
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
@@ -87,7 +262,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 +273,75 @@ 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(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
params url.Values,
|
||||
) (int, error) {
|
||||
// Fetch CSRF crumb before POST request (only if authenticated)
|
||||
var crumb *CrumbResponse
|
||||
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
|
||||
var err error
|
||||
crumb, err = jenkins.getCrumb(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get crumb: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
requestURL := jenkins.buildURL(path, params)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := jenkins.sendRequest(req, crumb)
|
||||
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,20 +359,144 @@ 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(ctx context.Context, queueID int) (*QueueItem, error) {
|
||||
path := fmt.Sprintf("/queue/item/%d/api/json", queueID)
|
||||
|
||||
var queueItem QueueItem
|
||||
err := jenkins.get(ctx, 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(
|
||||
ctx context.Context,
|
||||
job string,
|
||||
buildNumber int,
|
||||
) (*BuildInfo, error) {
|
||||
path := fmt.Sprintf("%s/%d/api/json", jenkins.parseJobPath(job), buildNumber)
|
||||
|
||||
var buildInfo BuildInfo
|
||||
err := jenkins.get(ctx, 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(
|
||||
ctx context.Context,
|
||||
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(ctx, 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(ctx, 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(ctx context.Context, job string, params url.Values) (int, error) {
|
||||
// Add remote trigger token to params
|
||||
if jenkins.Token != "" {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
params.Set("token", jenkins.Token)
|
||||
params.Set(tokenParam, jenkins.Token)
|
||||
}
|
||||
|
||||
var urlPath string
|
||||
// Check if params contains build parameters (excluding 'token')
|
||||
hasBuildParams := false
|
||||
for key := range params {
|
||||
if key != "token" {
|
||||
if key != tokenParam {
|
||||
hasBuildParams = true
|
||||
break
|
||||
}
|
||||
@@ -140,6 +508,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 == tokenParam {
|
||||
// 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(ctx, urlPath, params)
|
||||
}
|
||||
|
||||
+719
-10
@@ -1,10 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,7 +19,16 @@ func TestParseJobPath(t *testing.T) {
|
||||
Username: "appleboy",
|
||||
Token: "1234",
|
||||
}
|
||||
jenkins := NewJenkins(auth, "http://example.com", "", false)
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
auth,
|
||||
testExampleURL,
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "/job/foo", jenkins.parseJobPath("/foo/"))
|
||||
assert.Equal(t, "/job/foo", jenkins.parseJobPath("foo/"))
|
||||
@@ -24,13 +38,15 @@ func TestParseJobPath(t *testing.T) {
|
||||
|
||||
func TestUnSupportProtocol(t *testing.T) {
|
||||
auth := &Auth{
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
}
|
||||
jenkins := NewJenkins(auth, "example.com", "", false)
|
||||
jenkins, err := NewJenkins(context.Background(), auth, "example.com", "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err := jenkins.trigger("drone-jenkins", nil)
|
||||
queueID, err := jenkins.trigger(context.Background(), "drone-jenkins", nil)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, 0, queueID)
|
||||
}
|
||||
|
||||
func TestTriggerBuild(t *testing.T) {
|
||||
@@ -38,20 +54,713 @@ 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()
|
||||
|
||||
auth := &Auth{
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
}
|
||||
jenkins := NewJenkins(auth, server.URL, "remote-token", false)
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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(context.Background(), "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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queueID, err := jenkins.postAndGetLocation(context.Background(), "/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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queueItem, err := jenkins.getQueueItem(context.Background(), 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: testJobName,
|
||||
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: testJobName,
|
||||
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: testJobName,
|
||||
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: testJobName,
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buildInfo, err := jenkins.getBuildInfo(context.Background(), 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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buildInfo, err := jenkins.waitForCompletion(
|
||||
context.Background(),
|
||||
testJobName,
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buildInfo, err := jenkins.waitForCompletion(
|
||||
context.Background(),
|
||||
testJobName,
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buildInfo, err := jenkins.waitForCompletion(
|
||||
context.Background(),
|
||||
testJobName,
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buildInfo, err := jenkins.waitForCompletion(
|
||||
context.Background(),
|
||||
testJobName,
|
||||
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(context.Background(), "")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, data)
|
||||
})
|
||||
|
||||
t.Run("PEM content directly", func(t *testing.T) {
|
||||
data, err := loadCACert(context.Background(), 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(context.Background(), " \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(context.Background(), 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(context.Background(), "/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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), "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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
invalidPEM := "-----BEGIN CERTIFICATE-----\ninvalid-base64-data\n-----END CERTIFICATE-----"
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
// When insecure is true, CA cert should be ignored
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
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: testUserName,
|
||||
Token: testUserName,
|
||||
}
|
||||
jenkins, err := NewJenkins(
|
||||
context.Background(),
|
||||
auth,
|
||||
"https://example.com",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
false,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, jenkins)
|
||||
assert.NotNil(t, jenkins.Client)
|
||||
// Client should have CookieJar for CSRF session management
|
||||
assert.NotNil(t, jenkins.Client.Jar)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -61,7 +71,7 @@ func main() {
|
||||
EnvVars: []string{"PLUGIN_USER", "JENKINS_USER", "INPUT_USER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Name: tokenParam,
|
||||
Aliases: []string{"t"},
|
||||
Usage: "jenkins API token for authentication",
|
||||
EnvVars: []string{"PLUGIN_TOKEN", "JENKINS_TOKEN", "INPUT_TOKEN"},
|
||||
@@ -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
|
||||
@@ -134,7 +175,7 @@ func run(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Validate authentication: either (user + token) or remote-token must be provided
|
||||
hasUserAuth := c.String("user") != "" && c.String("token") != ""
|
||||
hasUserAuth := c.String("user") != "" && c.String(tokenParam) != ""
|
||||
hasRemoteToken := c.String("remote-token") != ""
|
||||
|
||||
if !hasUserAuth && !hasRemoteToken {
|
||||
@@ -142,14 +183,58 @@ 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(tokenParam),
|
||||
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"),
|
||||
}
|
||||
|
||||
return plugin.Exec()
|
||||
// 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(c.Context)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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 +44,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 +72,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
|
||||
}
|
||||
|
||||
@@ -69,19 +88,23 @@ func (p Plugin) validateConfig() error {
|
||||
if p.BaseURL == "" {
|
||||
return errors.New("jenkins base URL is required")
|
||||
}
|
||||
if p.Username == "" {
|
||||
return errors.New("jenkins username is required")
|
||||
}
|
||||
if p.Token == "" {
|
||||
return errors.New("jenkins API token is required")
|
||||
|
||||
// Validate authentication: either (user + token) or remote-token must be provided
|
||||
hasUserAuth := p.Username != "" && p.Token != ""
|
||||
hasRemoteToken := p.RemoteToken != ""
|
||||
|
||||
if !hasUserAuth && !hasRemoteToken {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec executes the plugin by triggering the configured Jenkins jobs.
|
||||
// It validates the configuration, parses parameters, and triggers each job sequentially.
|
||||
// Returns an error if validation fails or any job trigger fails.
|
||||
func (p Plugin) Exec() error {
|
||||
// The context can be used to cancel operations mid-execution.
|
||||
func (p Plugin) Exec(ctx context.Context) error {
|
||||
// Validate required configuration
|
||||
if err := p.validateConfig(); err != nil {
|
||||
return fmt.Errorf("configuration error: %w", err)
|
||||
@@ -93,24 +116,68 @@ func (p Plugin) Exec() error {
|
||||
return errors.New("at least one Jenkins job name is required")
|
||||
}
|
||||
|
||||
// Set up authentication
|
||||
auth := &Auth{
|
||||
Username: p.Username,
|
||||
Token: p.Token,
|
||||
// Set up authentication (only if username and token are provided)
|
||||
var auth *Auth
|
||||
if p.Username != "" && p.Token != "" {
|
||||
auth = &Auth{
|
||||
Username: p.Username,
|
||||
Token: p.Token,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Jenkins client
|
||||
jenkins := NewJenkins(auth, p.BaseURL, p.RemoteToken, p.Insecure)
|
||||
jenkins, err := NewJenkins(ctx, 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(ctx, 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(
|
||||
ctx,
|
||||
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
|
||||
|
||||
+209
-73
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -9,6 +11,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 {
|
||||
@@ -24,28 +32,55 @@ func TestValidateConfig(t *testing.T) {
|
||||
errorMsg: "jenkins base URL is required",
|
||||
},
|
||||
{
|
||||
name: "missing username and token",
|
||||
name: "missing authentication",
|
||||
plugin: Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
BaseURL: testExampleURL,
|
||||
},
|
||||
wantError: true,
|
||||
errorMsg: "jenkins username is required",
|
||||
errorMsg: testAuthRequiredErr,
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
name: "missing token (only username)",
|
||||
plugin: Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
BaseURL: testExampleURL,
|
||||
Username: testUserFoo,
|
||||
},
|
||||
wantError: true,
|
||||
errorMsg: "jenkins API token is required",
|
||||
errorMsg: testAuthRequiredErr,
|
||||
},
|
||||
{
|
||||
name: "all required config present",
|
||||
name: "missing username (only token)",
|
||||
plugin: Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
BaseURL: testExampleURL,
|
||||
Token: testUserBar,
|
||||
},
|
||||
wantError: true,
|
||||
errorMsg: testAuthRequiredErr,
|
||||
},
|
||||
{
|
||||
name: "user and token auth",
|
||||
plugin: Plugin{
|
||||
BaseURL: testExampleURL,
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "remote token auth",
|
||||
plugin: Plugin{
|
||||
BaseURL: testExampleURL,
|
||||
RemoteToken: testRemoteTokenValue,
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "both auth methods",
|
||||
plugin: Plugin{
|
||||
BaseURL: testExampleURL,
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
RemoteToken: testRemoteTokenValue,
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
@@ -83,7 +118,7 @@ func TestTrimWhitespaceFromSlice(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "all whitespace",
|
||||
input: []string{" ", "\t", "\n"},
|
||||
input: []string{testWhitespaceVal, "\t", "\n"},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
@@ -94,12 +129,12 @@ func TestTrimWhitespaceFromSlice(t *testing.T) {
|
||||
{
|
||||
name: "trim surrounding whitespace",
|
||||
input: []string{" foo ", " bar ", "baz"},
|
||||
expected: []string{"foo", "bar", "baz"},
|
||||
expected: []string{testUserFoo, testUserBar, "baz"},
|
||||
},
|
||||
{
|
||||
name: "mixed empty and valid",
|
||||
input: []string{"", "valid", "", "also-valid", ""},
|
||||
expected: []string{"valid", "also-valid"},
|
||||
input: []string{"", testValidStr, "", "also-valid", ""},
|
||||
expected: []string{testValidStr, "also-valid"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -115,68 +150,84 @@ 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"},
|
||||
testParamKey1: []string{testParamValue1},
|
||||
testParamKey2: []string{testParamValue2},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"},
|
||||
testParamKey: []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"},
|
||||
testParamKey: []string{"value with spaces"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parameter with empty value",
|
||||
input: []string{"key="},
|
||||
input: "key=",
|
||||
expected: url.Values{
|
||||
"key": []string{""},
|
||||
testParamKey: []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"},
|
||||
testValidStr: []string{"yes"},
|
||||
"also": []string{testValidStr},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key with surrounding whitespace",
|
||||
input: []string{" key =value"},
|
||||
input: " key =value",
|
||||
expected: url.Values{
|
||||
"key": []string{"value"},
|
||||
testParamKey: []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{
|
||||
testParamKey1: []string{testParamValue1},
|
||||
testParamKey2: []string{testParamValue2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lines with whitespace only",
|
||||
input: "key1=value1\n \n\t\nkey2=value2",
|
||||
expected: url.Values{
|
||||
testParamKey1: []string{testParamValue1},
|
||||
testParamKey2: []string{testParamValue2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -191,7 +242,7 @@ func TestParseParameters(t *testing.T) {
|
||||
func TestExecMissingConfig(t *testing.T) {
|
||||
var plugin Plugin
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "configuration error")
|
||||
@@ -201,28 +252,28 @@ func TestExecMissingConfig(t *testing.T) {
|
||||
// TestExecMissingJenkinsUsername tests Exec with missing username
|
||||
func TestExecMissingJenkinsUsername(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
BaseURL: testExampleURL,
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "configuration error")
|
||||
assert.Contains(t, err.Error(), "jenkins username is required")
|
||||
assert.Contains(t, err.Error(), testAuthRequiredErr)
|
||||
}
|
||||
|
||||
// TestExecMissingJenkinsToken tests Exec with missing token
|
||||
func TestExecMissingJenkinsToken(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
BaseURL: testExampleURL,
|
||||
Username: testUserFoo,
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "configuration error")
|
||||
assert.Contains(t, err.Error(), "jenkins API token is required")
|
||||
assert.Contains(t, err.Error(), testAuthRequiredErr)
|
||||
}
|
||||
|
||||
// TestExecMissingJenkinsJob tests Exec with missing or empty job list
|
||||
@@ -237,7 +288,7 @@ func TestExecMissingJenkinsJob(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "only whitespace jobs",
|
||||
jobs: []string{" ", "\t", "\n"},
|
||||
jobs: []string{testWhitespaceVal, "\t", "\n"},
|
||||
},
|
||||
{
|
||||
name: "nil jobs",
|
||||
@@ -248,13 +299,13 @@ func TestExecMissingJenkinsJob(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
BaseURL: testExampleURL,
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: tt.jobs,
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one Jenkins job name is required")
|
||||
})
|
||||
@@ -264,19 +315,23 @@ 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()
|
||||
|
||||
plugin := Plugin{
|
||||
BaseURL: server.URL,
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{"drone-jenkins"},
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -286,19 +341,24 @@ func TestExecTriggerMultipleJobs(t *testing.T) {
|
||||
// Create a mock Jenkins server
|
||||
jobsTriggered := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
jobsTriggered++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Only count POST requests (job triggers), not GET requests (crumb)
|
||||
if r.Method == "POST" {
|
||||
jobsTriggered++
|
||||
}
|
||||
w.Header().
|
||||
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
plugin := Plugin{
|
||||
BaseURL: server.URL,
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{"job1", "job2", "job3"},
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, jobsTriggered)
|
||||
@@ -310,19 +370,20 @@ 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()
|
||||
|
||||
plugin := Plugin{
|
||||
BaseURL: server.URL,
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{"parameterized-job"},
|
||||
Parameters: []string{"branch=main", "environment=production"},
|
||||
Parameters: "branch=main\nenvironment=production",
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "main", receivedQuery.Get("branch"))
|
||||
@@ -335,22 +396,23 @@ 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()
|
||||
|
||||
plugin := Plugin{
|
||||
BaseURL: server.URL,
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
RemoteToken: "remote-token-123",
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
RemoteToken: testRemoteTokenValue,
|
||||
Job: []string{"secure-job"},
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "remote-token-123", receivedToken)
|
||||
assert.Equal(t, testRemoteTokenValue, receivedToken)
|
||||
}
|
||||
|
||||
// TestExecWithJobsContainingWhitespace tests job list with whitespace
|
||||
@@ -358,21 +420,95 @@ func TestExecWithJobsContainingWhitespace(t *testing.T) {
|
||||
// Create a mock Jenkins server
|
||||
jobsTriggered := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
jobsTriggered++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Only count POST requests (job triggers), not GET requests (crumb)
|
||||
if r.Method == "POST" {
|
||||
jobsTriggered++
|
||||
}
|
||||
w.Header().
|
||||
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
plugin := Plugin{
|
||||
BaseURL: server.URL,
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Job: []string{" job1 ", "job2", " ", "job3"},
|
||||
Username: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{" job1 ", "job2", testWhitespaceVal, "job3"},
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
// 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: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{testJobName},
|
||||
Wait: true,
|
||||
}
|
||||
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
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: testUserFoo,
|
||||
Token: testUserBar,
|
||||
Job: []string{testJobName},
|
||||
Wait: true,
|
||||
}
|
||||
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed with status: FAILURE")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
testUserFoo = "foo"
|
||||
testUserBar = "bar"
|
||||
testUserName = "test"
|
||||
testJobName = "test-job"
|
||||
testExampleURL = "http://example.com"
|
||||
testAuthRequiredErr = "authentication required"
|
||||
testRemoteTokenValue = "remote-token-123"
|
||||
testWhitespaceVal = " "
|
||||
testValidStr = "valid"
|
||||
testParamKey = "key"
|
||||
testParamKey1 = "key1"
|
||||
testParamKey2 = "key2"
|
||||
testParamValue1 = "value1"
|
||||
testParamValue2 = "value2"
|
||||
)
|
||||
Reference in New Issue
Block a user