Compare commits

...

44 Commits

Author SHA1 Message Date
dependabot[bot] ae447fe006 build(deps): bump aquasecurity/trivy-action in /.github/workflows
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.33.1 to 0.34.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.33.1...0.34.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.34.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-18 15:57:48 +00:00
appleboy f2a83d3d6c docs: document Jenkins authentication and CSRF protection methods
- Clarify and expand Jenkins authentication documentation, outlining API token, remote trigger token, and combined authentication options
- Add detailed explanations and requirements for working with CSRF protection in Jenkins, including error guidance
- Modify authentication requirements and usage examples to emphasize API token over remote trigger token, especially for secure Jenkins setups
- Introduce a Troubleshooting section with solutions for common Jenkins authentication errors (403, 401, remote token issues)
- Add corresponding CSRF protection notices and troubleshooting guidance to Chinese (Simplified and Traditional) documentation

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 11:56:15 +08:00
Bo-Yi Wu 351ac33e2d feat: refactor authentication logic and broaden test coverage (#50)
* feat: refactor authentication logic and broaden test coverage

- Improve authentication checks to only require username and token when both are provided
- Update validation logic to allow either (username and token) or remote-token for authentication
- Enhance test coverage for various authentication scenarios
- Refine error messages to indicate a generic authentication requirement instead of specifying missing username or token

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* style: streamline authentication error handling in config validation

- Simplify authentication error message in config validation

Signed-off-by: appleboy <appleboy.tw@gmail.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 11:25:36 +08:00
Bo-Yi Wu 984ca01afc feat: add CSRF crumb support and session management for Jenkins API (#49)
- Add support for Jenkins CSRF protection by managing and adding CSRF crumb to POST requests
- Store and cache CSRF crumb after fetching from Jenkins for session reuse
- Use an HTTP client with CookieJar for session management to support CSRF crumb handling
- Update existing tests to check for presence of CookieJar in the HTTP client
- Improve test servers to only count job trigger POSTs, ignoring GET requests for crumbs

fix #48

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-27 10:48:26 +08:00
appleboy d30890d257 ci: update GitHub Actions dependencies to latest major versions
- Update actions/checkout to version 6 in all workflows
- Update github/codeql-action steps to version 4 in the CodeQL workflow
- Upgrade actions/setup-go to version 6 in docker and goreleaser workflows
- Upgrade actions/cache to version 5 in the lint workflow

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-26 16:50:50 +08:00
appleboy 3309475595 refactor: add context.Context support across Jenkins client and execution
- Add support for passing context.Context throughout Jenkins client and related functions for better cancellation and timeout handling
- Update plugin execution and main entrypoint to accept context, enabling propagating cancellation signals
- Refactor tests to provide context when calling functions that now require it

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-26 16:41:00 +08:00
appleboy 2b5c542196 docs: revise documentation for multi-platform CI/CD support
- Update project description to emphasize multi-platform CI/CD compatibility beyond Drone
- Remove outdated references to Drone CI and simplify the supported usage forms; project is now offered as a CLI tool or Docker image
- Add support mentions for GitHub Actions, GitLab CI, and Gitea Action across documentation
- Eliminate usage examples and documentation sections specific to Drone CI from all language versions
- Clarify that integration examples are covered in a separate documentation file

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-26 15:54:17 +08:00
appleboy d8cceb3839 docs: add multilingual README and enhance usage documentation
- Add links for English, Traditional Chinese, and Simplified Chinese translations at the top of the README
- Introduce a new "Why drone-jenkins?" section explaining the use cases and benefits for hybrid CI/CD environments
- Expand the table of contents with new sections reflecting the README structure
- Correct the formatting of the parameters table for clarity
- Add a Simplified Chinese README translation (README.zh-CN.md)
- Add a Traditional Chinese README translation (README.zh-TW.md)

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-26 15:44:17 +08:00
appleboy 6a8250d7e6 docs: document Makefile target discovery with make help
- Document the usage of the `make help` command to display available Makefile targets

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-20 12:00:39 +08:00
appleboy 8dfbb92314 build: refactor Makefile to enhance usability and add cross-compilation
- Add help descriptions to Makefile targets using double hashes, improving 'make help' documentation
- Implement a 'help' target to print available Makefile commands and their descriptions
- Add explicit .PHONY lines for all make targets to clarify intent
- Add linux/amd64, linux/arm64, and linux/arm build targets for cross-compilation
- Improve test, lint, fmt, install, build, and clean target implementations
- Add clean target to remove build artifacts and coverage files
- Add version target to print current project version

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-06 18:31:26 +08:00
appleboy b0761290c2 docs: document project and enforce style with lint targets
- Add a CLAUDE.md guide describing project purpose, build instructions, architecture, and developer patterns
- Add linting and formatting targets (lint, fmt) to Makefile using golangci-lint

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-06 18:27:45 +08:00
Bo-Yi Wu 02829360ad feat: add support for custom CA certificates for SSL/TLS connections (#42)
* feat: add support for custom CA certificates for SSL/TLS connections

- Add support for custom CA certificates (via PEM content, file path, or HTTP/HTTPS URL) for SSL/TLS connections.
- Document the new CA certificate option and usage examples for CLI, Docker, and Drone CI in the README.
- Update Jenkins client initialization to load and validate a custom CA certificate if provided, using a priority where insecure mode overrides custom CA.
- Introduce comprehensive tests for CA certificate loading and Jenkins client initialization with different CA certificate sources and error scenarios.
- Register the new ca-cert command-line flag and propagate its value through configuration and debug output.
- Ensure that error handling for certificate loading fully propagates failures.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* test: update test CA certificate with new sample

- Replace the sample CA certificate used in tests with a new certificate

Signed-off-by: appleboy <appleboy.tw@gmail.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-06 13:11:29 +08:00
appleboy f3a67c62a6 chore: update third-party libraries to latest compatible versions
- Update urfave/cli library to version 2.27.7
- Update xrash/smetrics library to a newer commit

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-06 10:58:35 +08:00
Bo-Yi Wu 069e6455cc ci: update build workflow and dependencies for Go 1.24 compatibility
- Update Go version requirement from 1.22 to 1.24.0
- Add github.com/appleboy/com as a dependency
- Set GitHub Actions output with build result and URL after Jenkins build completion
- Log a warning if setting GitHub output fails

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 19:42:44 +08:00
Bo-Yi Wu eb51e55e81 build: enable detailed build info logging in debug mode
- Add debug logging to display final build information when debug mode is enabled

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 19:40:30 +08:00
Bo-Yi Wu da87ddb86b docs: document and illustrate debug mode configuration options
- Document the new debug mode, including its purpose and usage
- Add examples showing how to enable debug mode via CLI, Docker, and YAML configuration
- Update the configuration table to describe the debug option and its environment variables

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 17:25:40 +08:00
Bo-Yi Wu 60874908e6 docs: document and standardize multi-line build parameter configurations
- Add example configurations for build parameters and waiting for job completion
- Document new settings: wait, poll_interval, timeout, insecure, and remote_token
- Update parameters format to require multi-line key=value pairs (one per line)
- Clarify handling of whitespace, empty lines, and multiple equals signs in parameter values
- Revise CLI and Docker usage examples to use multi-line parameters format
- Update YAML configuration example to use multi-line parameters block

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 17:09:52 +08:00
Bo-Yi Wu f6e62d9c49 feat: switch Jenkins build parameters to multi-line string format
- Change the Jenkins build parameters input from a string slice to a multi-line string format
- Update parameter parsing to handle multi-line strings, skipping empty and whitespace-only lines
- Adjust all usages and tests to support the new string-based parameter format
- Add test cases for multiple empty lines and lines containing only whitespace in parameters

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 16:55:46 +08:00
Bo-Yi Wu cf9b9a0a0d feat: add debug mode with enhanced logging and token masking (#41)
* feat: add debug mode with enhanced logging and token masking

- Add godump library dependency for improved debug output
- Introduce a debug mode flag to the Jenkins and Plugin structs
- Update NewJenkins constructor and all usages to support the debug flag
- Mask sensitive tokens in debug output for security
- Log detailed parameter and configuration information when debug mode is enabled
- Add CLI flag to enable debug mode via environment variables

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* fix: add error handling and logging for godump.Dump failures

- Add error handling for godump.Dump calls, logging a warning if dumping fails

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 15:11:39 +08:00
Bo-Yi Wu 4c54d13899 docs: simplify Jenkins Docker setup and improve reliability
- Update Jenkins Docker run instructions to use a named volume and the slim image
- Change container restart policy to on-failure
- Remove note about creating a host directory for Jenkins data

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-02 14:36:48 +08:00
Bo-Yi Wu 627e233cc6 feat: add configurable Jenkins job wait and polling support (#40)
- Add support for waiting for Jenkins job completion with polling and timeout options
- Introduce flags and environment variables to control wait behavior, poll interval, and timeout
- Refactor the Jenkins job trigger to return queue item ID and enable job status tracking
- Implement functions for querying queue items and build status, including waiting until a build completes or times out
- Update CLI, plugin, and documentation to explain new job wait and polling capabilities
- Extend tests to cover queue item parsing, build info retrieval, and job completion scenarios with success, failure, and timeouts

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-12-01 21:53:42 +08:00
Bo-Yi Wu 747d7b23d1 docs: update Jenkins API token creation instructions and images
- Update instructions to select "Security" instead of "Configure" when creating a Jenkins API token
- Replace the token creation image with a new screenshot
- Remove the old jenkins-token.png image and add personal-token.png

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:45:49 +08:00
Bo-Yi Wu 48d5425edd ci: use Docker-based linting in CI workflow
- Update the lint workflow to use the Dockerfile located in the docker directory

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:41:10 +08:00
Bo-Yi Wu cda84c122e feat: refactor plugin config validation and enhance job error handling
- Refactor plugin configuration validation into a dedicated method with improved error messages
- Add utility functions for trimming whitespace from slices and parsing key=value parameters
- Improve error handling for missing or invalid job names and parameters
- Enhance Exec to validate configuration, clean job list, and parse parameters before triggering jobs
- Update job triggering to provide more descriptive error reporting
- Replace deprecated and less precise test assertions with more explicit ones
- Add extensive unit tests for configuration validation, whitespace trimming, parameter parsing, and job triggering (including multiple jobs, parameters, remote token, and job lists with whitespace)
- Improve test coverage and reliability for plugin behavior and edge cases

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:39:36 +08:00
Bo-Yi Wu afbea8106e refactor: refactor Jenkins struct by removing unused methods
- Remove the parseResponse method from the Jenkins struct

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:29:58 +08:00
Bo-Yi Wu cc67514a1a docs: revamp README with expanded setup, usage, and integration docs
- Rewrite and expand the README with a new structure, including a Table of Contents, Features, Prerequisites, Installation, Configuration, Usage, Development, License, and Contributing sections
- Add detailed installation instructions for binary, source, and Docker image
- Provide step-by-step Jenkins server setup and authentication guidance
- Introduce a comprehensive parameters reference table for configuration
- Clarify authentication requirements and usage scenarios for CLI, Docker, and Drone CI
- Update environment variable names for consistency (use JENKINS_URL instead of JENKINS_BASE_URL)
- Add multiple usage examples for single/multiple jobs and parameterized builds
- Include instructions and examples for integrating with Drone CI pipelines
- Add development instructions for building, testing, and test coverage
- Add license and contributing information at the end of the README

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:28:52 +08:00
Bo-Yi Wu 3d7ffaed68 build: ensure Go build/install uses explicit current directory
- Add explicit current directory (.) to Go build and install commands to ensure correct build context

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:25:51 +08:00
Bo-Yi Wu 52abef124e refactor: improve CLI robustness and Jenkins integration
- Add missing .PHONY targets to the Makefile for better build reliability
- Ensure HTTP response bodies are always read and closed in Jenkins post requests
- Replace custom response parsing with direct JSON unmarshalling in Jenkins post
- Set a default value for the Version variable
- Move ASCII art to a constant and reuse for CLI help template
- Improve dotenv loading error handling and logging in main
- Update repository link in CLI help output
- Add validation for required CLI parameters and authentication in run function

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:23:52 +08:00
Bo-Yi Wu 3dd86f956c feat: add remote trigger token support for Jenkins job execution
- Add support for passing a remote trigger token to Jenkins jobs
- Update the Jenkins constructor to accept a token parameter
- Ensure the token is included as a query parameter when triggering jobs
- Improve error reporting by including response body in error messages
- Remove unnecessary logging and refactor build parameter logic
- Update tests to use the new Jenkins constructor and token handling
- Add CLI option for specifying a remote trigger token
- Extend plugin configuration to support remote token injection

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 17:16:39 +08:00
Bo-Yi Wu a5469c939e refactor: run container as non-root dedicated drone user
- Add a dedicated drone user and group for running the container
- Change file ownership of the drone-jenkins binary to the drone user
- Switch container execution to use the drone user instead of root

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 16:32:52 +08:00
Steve Liang a6d967789d chore(api): check Jenkins response status (#39)
When triggering a Jenkins job, Jenkins API call returns 200 OK but drone-jenkins expects 201.  So, the trigger succeeded but drone-jenkins returns false negative.
2025-12-01 16:29:54 +08:00
Bo-Yi Wu 3eb2242053 chore: migrate CLI to urfave/cli/v2 and update flag handling
- Remove formatting and vetting targets from the Makefile
- Upgrade urfave/cli dependency from v1 to v2
- Update CLI flag definitions to use urfave/cli/v2 API, including new struct field names and flag aliasing
- Add indirect dependency on github.com/xrash/smetrics

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 16:27:57 +08:00
Bo-Yi Wu 908be474f3 docs: add Trivy Security Scan badge to README
- Add a Trivy Security Scan badge to the README

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 16:24:13 +08:00
Bo-Yi Wu de494cbda1 feat: add context-aware HTTP POST and improve CLI help formatting
- Add context support to HTTP POST requests for improved cancellation and timeout handling
- Fix formatting of usage and commands help text in the CLI output

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 16:23:07 +08:00
Bo-Yi Wu 77ea4873e0 ci: modernize CI workflows and enhance security scanning
- Update GitHub Actions to use newer versions for setup-go, checkout, golangci-lint, and codecov
- Change Go version specification to use "stable" and update test matrix to only "1.25"
- Rename the test job to testing
- Adjust hadolint to use a newer version and reference the Dockerfile at the root
- Modify go test command to enable race detection and test all packages
- Add a new Trivy security scan workflow for vulnerability, secret, and misconfiguration checks, including SARIF upload and log output

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 16:21:24 +08:00
Bo-Yi Wu 36013b246a build: configure golangci-lint with custom rules and exclusions
- Add golangci-lint configuration with a custom set of enabled linters and formatters
- Exclude generated files and specific directories from linting and formatting

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 15:36:14 +08:00
Bo-Yi Wu cbfb2bb51b chore: update documentation and Docker base image version
- Fix formatting of platform list in the README
- Update Docker base image from alpine:3.20 to alpine:3.22

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 15:35:05 +08:00
Bo-Yi Wu b6589abef3 chore: update dependencies to latest stable versions
- Update testify dependency to v1.10.0
- Update urfave/cli dependency to v1.22.17
- Update go-md2man/v2 indirect dependency to v2.0.7

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-12-01 15:33:45 +08:00
appleboy 626b9e4bfa refactor: refactor codebase to use plural form 'parameters'
- Rename `parameter` to `parameters` in CLI flags and environment variables
- Update function calls to use `parameters` instead of `parameter`
- Change struct field `Parameter` to `Parameters`
- Modify loop variable to iterate over `Parameters` instead of `Parameter`

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-06 08:34:10 +08:00
appleboy e60e4b9161 chore: improve logging and testing across the codebase
- Add logging for URL path in the `trigger` function
- Remove commented-out form data encoding line
- Import the `log` package

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-06 07:59:12 +08:00
appleboy c213337305 ci: enhance CI/CD pipeline with GoReleaser integration
- Replace `go-version` with `go-version-file` and add `check-latest` in GitHub Actions workflow
- Add `.goreleaser.yaml` configuration file with build, archive, checksum, snapshot, release, and changelog settings

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-05 22:26:32 +08:00
Bo-Yi Wu a7c6b81621 feat: support parameter handling for dynamic URL paths (#35)
- Comment out unused form data encoding in `post` method
- Remove redundant error logging in `post` method
- Add conditional URL path selection in `trigger` method based on parameters
- Add `parameter` flag to CLI options in `main.go`
- Include `parameter` in the `run` function configuration
- Import `net/url` package in `plugin.go`
- Add `Parameter` field to `plugin.go` struct
- Parse and add parameters to URL values in `Exec` method
- Pass parsed parameters to `trigger` method in `Exec` function

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-05 20:25:01 +08:00
appleboy bb1e9fe4e0 feat: improve logging and error handling in Jenkins plugin
- Add error handling for unexpected response codes in `post` method of `jenkins.go`
- Import `log` package in `plugin.go`
- Add logging for successful job trigger in `Exec` method of `plugin.go`

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-05 13:28:14 +08:00
Bo-Yi Wu 1bb020b22d feat: add support for insecure TLS connections (#34)
- Add `crypto/tls` import in `jenkins.go`
- Add `Client` field to `Jenkins` struct
- Modify `NewJenkins` function to accept an `insecure` parameter and configure HTTP client accordingly
- Update `sendRequest` method to use the `Client` field from the `Jenkins` struct
- Update tests in `jenkins_test.go` to include the `insecure` parameter in `NewJenkins` calls
- Add `insecure` flag to CLI options in `main.go`
- Add `Insecure` field to `Plugin` struct
- Update `Plugin.Exec` method to pass `Insecure` field to `NewJenkins`

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2024-10-05 08:41:53 +08:00
23 changed files with 3691 additions and 260 deletions
+3 -3
View File
@@ -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
+2 -2
View File
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "^1"
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+4 -3
View File
@@ -13,13 +13,14 @@ 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: "^1"
go-version-file: go.mod
check-latest: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
+14 -13
View File
@@ -9,27 +9,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "^1"
go-version: "stable"
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: latest
version: v2.6
args: --verbose
- uses: hadolint/hadolint-action@v3.1.0
- uses: hadolint/hadolint-action@v3.3.0
name: hadolint for Dockerfile
with:
dockerfile: docker/Dockerfile
test:
testing:
strategy:
matrix:
os: [ubuntu-latest]
go: [1.22, 1.23]
go: ["1.25"]
include:
- os: ubuntu-latest
go-build: ~/.cache/go-build
@@ -40,16 +41,16 @@ jobs:
GOPROXY: https://proxy.golang.org
steps:
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: |
${{ matrix.go-build }}
@@ -59,9 +60,9 @@ jobs:
${{ runner.os }}-go-
- name: Run Tests
run: |
go test -v -covermode=atomic -coverprofile=coverage.out
go test -race -cover -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
flags: ${{ matrix.os }},go-${{ matrix.go }}
+56
View File
@@ -0,0 +1,56 @@
name: Trivy Security Scan
on:
push:
branches:
- master
pull_request:
branches:
- master
schedule:
# Run daily at 00:00 UTC
- cron: "0 0 * * *"
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
security-events: write # Required for uploading SARIF results
jobs:
trivy-scan:
name: Trivy Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run Trivy vulnerability scanner (source code)
uses: aquasecurity/trivy-action@0.34.0
with:
scan-type: "fs"
scan-ref: "."
scanners: "vuln,secret,misconfig"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM"
ignore-unfixed: true
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: "trivy-results.sarif"
- name: Run Trivy scanner (table output for logs)
uses: aquasecurity/trivy-action@0.34.0
if: always()
with:
scan-type: "fs"
scan-ref: "."
scanners: "vuln,secret,misconfig"
format: "table"
severity: "CRITICAL,HIGH,MEDIUM"
ignore-unfixed: true
exit-code: "1"
+51
View File
@@ -0,0 +1,51 @@
version: "2"
linters:
default: none
enable:
- bodyclose
- dogsled
- dupl
- errcheck
- exhaustive
- gochecknoinits
- goconst
- gocritic
- gocyclo
- goprintffuncname
- gosec
- govet
- ineffassign
- lll
- misspell
- nakedret
- noctx
- nolintlint
- rowserrcheck
- staticcheck
- unconvert
- unparam
- unused
- whitespace
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- gofumpt
- goimports
- golines
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+123
View File
@@ -0,0 +1,123 @@
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
- freebsd
goarch:
- amd64
- arm
- arm64
goarm:
- "5"
- "6"
- "7"
ignore:
- goos: darwin
goarch: arm
- goos: darwin
goarch: ppc64le
- goos: darwin
goarch: s390x
- goos: windows
goarch: ppc64le
- goos: windows
goarch: s390x
- goos: windows
goarch: arm
goarm: "5"
- goos: windows
goarch: arm
goarm: "6"
- goos: windows
goarch: arm
goarm: "7"
- goos: windows
goarch: arm64
- goos: freebsd
goarch: ppc64le
- goos: freebsd
goarch: s390x
- goos: freebsd
goarch: arm
goarm: "5"
- goos: freebsd
goarch: arm
goarm: "6"
- goos: freebsd
goarch: arm
goarm: "7"
- goos: freebsd
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
binary: >-
{{ .ProjectName }}-
{{- if .IsSnapshot }}{{ .Branch }}-
{{- else }}{{- .Version }}-{{ end }}
{{- .Os }}-
{{- if eq .Arch "amd64" }}amd64
{{- else if eq .Arch "amd64_v1" }}amd64
{{- else if eq .Arch "386" }}386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}-{{ .Arm }}{{ end }}
no_unique_dist_dir: true
hooks:
post:
- cmd: xz -k -9 {{ .Path }}
dir: ./dist/
archives:
- format: binary
name_template: "{{ .Binary }}"
allow_different_binary_count: true
checksum:
name_template: "checksums.txt"
extra_files:
- glob: ./**.xz
snapshot:
name_template: "{{ incpatch .Version }}"
release:
# You can add extra pre-existing files to the release.
# The filename on the release will be the last part of the path (base).
# If another file with the same name exists, the last one found will be used.
#
# Templates: allowed
extra_files:
- glob: ./**.xz
changelog:
use: github
groups:
- title: Features
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: "Bug fixes"
regexp: "^.*fix[(\\w)]*:+.*$"
order: 1
- title: "Enhancements"
regexp: "^.*chore[(\\w)]*:+.*$"
order: 2
- title: "Refactor"
regexp: "^.*refactor[(\\w)]*:+.*$"
order: 3
- title: "Build process updates"
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
order: 4
- title: "Documentation updates"
regexp: ^.*?docs?(\(.+\))??!?:.+$
order: 4
- title: Others
order: 999
+50
View File
@@ -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.
+49
View File
@@ -48,6 +48,37 @@ Example configuration with jobs in the folder:
It will trigger the URL of Jenkins job like as `http://example.com/job/folder_name/job/job_name/`
Example configuration with build parameters:
```yaml
- name: trigger jenkins job
image: appleboy/drone-jenkins
settings:
url: http://example.com
user: appleboy
token: xxxxxxxxxx
job: parameterized-job
parameters: |
ENVIRONMENT=production
VERSION=${DRONE_TAG}
COMMIT_SHA=${DRONE_COMMIT_SHA}
```
Example configuration with wait for completion:
```yaml
- name: trigger jenkins job and wait
image: appleboy/drone-jenkins
settings:
url: http://example.com
user: appleboy
token: xxxxxxxxxx
job: deploy-job
wait: true
poll_interval: 15s
timeout: 1h
```
## Parameter Reference
url
@@ -61,3 +92,21 @@ token
job
: jenkins job name
parameters
: build parameters in multi-line `key=value` format (one per line)
wait
: wait for job completion (default: false)
poll_interval
: interval between status checks when waiting (default: 10s)
timeout
: maximum time to wait for job completion (default: 30m)
insecure
: allow insecure SSL connections (default: false)
remote_token
: jenkins remote trigger token (alternative to user/token authentication)
+39 -35
View File
@@ -39,52 +39,56 @@ endif
TAGS ?=
LDFLAGS ?= -X 'main.Version=$(VERSION)'
all: build
.PHONY: all
all: build ## Build the project (default target)
fmt:
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install mvdan.cc/gofumpt; \
fi
$(GOFMT) -w $(GOFILES)
vet:
$(GO) vet ./...
.PHONY: fmt-check
fmt-check:
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install mvdan.cc/gofumpt; \
fi
@diff=$$($(GOFMT) -d $(GOFILES)); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
echo "$${diff}"; \
exit 1; \
fi;
test:
.PHONY: test
test: ## Run tests with coverage
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
install: $(GOFILES)
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
.PHONY: lint
lint: ## Run golangci-lint
golangci-lint run -v
build: $(EXECUTABLE)
.PHONY: fmt
fmt: ## Format code with golangci-lint
golangci-lint fmt
.PHONY: install
install: $(GOFILES) ## Install binary to GOPATH/bin
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' .
.PHONY: build
build: $(EXECUTABLE) ## Build binary to bin/
$(EXECUTABLE): $(GOFILES)
$(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o bin/$@
$(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o bin/$@ .
build_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)
.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:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -a -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o release/linux/arm64/$(DEPLOY_IMAGE)
.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:
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)
.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)
+465 -58
View File
@@ -1,66 +1,243 @@
# drone-jenkins
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
![logo](./images/logo.png)
[![Lint and Testing](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
[![Trivy Security Scan](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
[![GoDoc](https://godoc.org/github.com/appleboy/drone-jenkins?status.svg)](https://godoc.org/github.com/appleboy/drone-jenkins)
[![codecov](https://codecov.io/gh/appleboy/drone-jenkins/branch/master/graph/badge.svg)](https://codecov.io/gh/appleboy/drone-jenkins)
[![Go Report Card](https://goreportcard.com/badge/github.com/appleboy/drone-jenkins)](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
[Drone](https://github.com/drone/drone) plugin for trigger [Jenkins](https://jenkins.io/) jobs.
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.
## Setup the Jenkins Server
## Why drone-jenkins?
Setup the Jenkins server using the docker command:
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)
- [Installation](#installation)
- [Download Binary](#download-binary)
- [Build from Source](#build-from-source)
- [Docker Image](#docker-image)
- [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)
- [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)
- [License](#license)
- [Contributing](#contributing)
## Features
- Trigger single or multiple Jenkins jobs
- Support for Jenkins build parameters
- Multiple authentication methods (API token or remote trigger token)
- Wait for job completion with configurable polling and timeout
- 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 CLI binary or Docker image
## Prerequisites
- Jenkins server (version 2.0 or later recommended)
- Jenkins API token or remote trigger token for authentication
- For Jenkins setup, Docker is recommended but not required
## Installation
### Download Binary
Pre-compiled binaries are available from the [release page](https://github.com/appleboy/drone-jenkins/releases) for:
- **Linux**: amd64, 386
- **macOS (Darwin)**: amd64, 386
- **Windows**: amd64, 386
With Go installed, you can also install directly:
```sh
$ docker run \
--name jenkins \
-d --restart always \
-p 8080:8080 -p 50000:50000 \
-v /data/jenkins:/var/jenkins_home \
jenkins/jenkins:lts
go install github.com/appleboy/drone-jenkins@latest
```
Please make sure that you create the `/data/jenkins` before starting the Jenkins. Create the new API token as below:
### Build from Source
![jenkins token](./images/jenkins-token.png)
## Build or Download a binary
The pre-compiled binaries can be downloaded from [release page](https://github.com/appleboy/drone-jenkins/releases). Support the following OS type.
* Windows amd64/386
* Linux amd64/386
* Darwin amd64/386
With `Go` installed
```sh
go install github.com/appleboy/drone-jenkins
```
or build the binary with the following command:
Clone the repository and build:
```sh
git clone https://github.com/appleboy/drone-jenkins.git
cd drone-jenkins
make build
```
## Docker
### Docker Image
Build the docker image with the following commands:
Build the Docker image:
```sh
make docker
```
Or pull the pre-built image:
```sh
docker pull ghcr.io/appleboy/drone-jenkins
```
## Configuration
### Jenkins Server Setup
Set up a Jenkins server using Docker:
```sh
docker run -d -v jenkins_home:/var/jenkins_home -p 8080:8080 -p 50000:50000 --restart=on-failure jenkins/jenkins:slim
```
### Authentication
#### 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)
3. Select "Security"
4. Under "API Token", click "Add new Token"
5. Give it a name and click "Generate"
6. Copy the generated token
![personal token](./images/personal-token.png)
**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 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**:
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
There are three ways to trigger jenkins jobs.
### Command Line
### Usage from binary
trigger single job.
**Single job:**
```bash
drone-jenkins \
@@ -70,7 +247,7 @@ drone-jenkins \
--job drone-jenkins-plugin
```
trigger multiple jobs.
**Multiple jobs:**
```bash
drone-jenkins \
@@ -81,51 +258,281 @@ drone-jenkins \
--job drone-jenkins-plugin-2
```
### Usage from docker
**With build parameters:**
trigger single job.
```bash
drone-jenkins \
--host http://jenkins.example.com/ \
--user appleboy \
--token XXXXXXXX \
--job my-jenkins-job \
--parameters $'ENVIRONMENT=production\nVERSION=1.0.0'
```
Or using environment variable:
```bash
export JENKINS_PARAMETERS="ENVIRONMENT=production
VERSION=1.0.0
BRANCH=main"
drone-jenkins \
--host http://jenkins.example.com/ \
--user appleboy \
--token XXXXXXXX \
--job my-jenkins-job
```
**Using 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:**
```bash
docker run --rm \
-e JENKINS_BASE_URL=http://jenkins.example.com/
-e JENKINS_USER=appleboy
-e JENKINS_TOKEN=xxxxxxx
-e JENKINS_JOB=drone-jenkins-plugin
-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
```
trigger multiple jobs.
**Multiple jobs:**
```bash
docker run --rm \
-e JENKINS_BASE_URL=http://jenkins.example.com/
-e JENKINS_USER=appleboy
-e JENKINS_TOKEN=xxxxxxx
-e JENKINS_JOB=drone-jenkins-plugin-1,drone-jenkins-plugin-2
-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
```
### Usage from drone ci
**With build parameters:**
Execute from the working directory:
```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
```
**With combined authentication (API token + remote token):**
```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
```
**Wait for job completion:**
```bash
docker run --rm \
-e JENKINS_URL=http://jenkins.example.com/ \
-e JENKINS_USER=appleboy \
-e JENKINS_TOKEN=xxxxxxx \
-e JENKINS_JOB=my-jenkins-job \
-e JENKINS_WAIT=true \
-e JENKINS_POLL_INTERVAL=15s \
-e JENKINS_TIMEOUT=1h \
ghcr.io/appleboy/drone-jenkins
```
**With debug mode:**
```bash
docker run --rm \
-e JENKINS_URL=http://jenkins.example.com/ \
-e JENKINS_USER=appleboy \
-e JENKINS_TOKEN=xxxxxxx \
-e JENKINS_JOB=my-jenkins-job \
-e JENKINS_DEBUG=true \
ghcr.io/appleboy/drone-jenkins
```
**With custom CA certificate:**
```bash
# Using a mounted certificate file
docker run --rm \
-v /path/to/ca.pem:/ca.pem:ro \
-e JENKINS_URL=https://jenkins.example.com/ \
-e JENKINS_USER=appleboy \
-e JENKINS_TOKEN=xxxxxxx \
-e JENKINS_JOB=my-jenkins-job \
-e JENKINS_CA_CERT=/ca.pem \
ghcr.io/appleboy/drone-jenkins
# Using a URL
docker run --rm \
-e JENKINS_URL=https://jenkins.example.com/ \
-e JENKINS_USER=appleboy \
-e JENKINS_TOKEN=xxxxxxx \
-e JENKINS_JOB=my-jenkins-job \
-e JENKINS_CA_CERT=https://example.com/ca-bundle.crt \
ghcr.io/appleboy/drone-jenkins
```
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
Build the binary:
```sh
docker run --rm \
-e PLUGIN_URL=http://example.com \
-e PLUGIN_USER=xxxxxxx \
-e PLUGIN_TOKEN=xxxxxxx \
-e PLUGIN_JOB=xxxxxxx \
-v $(pwd):$(pwd) \
-w $(pwd) \
ghcr.io/appleboy/drone-jenkins
make build
```
You can get more [information](DOCS.md) about how to use scp plugin in drone.
Build the Docker image:
## Testing
```sh
make docker
```
Test the package with the following command:
### Testing
Run the test suite:
```sh
make test
```
Run tests with coverage:
```sh
make test-coverage
```
## License
Copyright (c) 2019 Bo-Yi Wu
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+412
View File
@@ -0,0 +1,412 @@
# drone-jenkins
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
![logo](./images/logo.png)
[![Lint and Testing](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
[![Trivy Security Scan](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
[![GoDoc](https://godoc.org/github.com/appleboy/drone-jenkins?status.svg)](https://godoc.org/github.com/appleboy/drone-jenkins)
[![codecov](https://codecov.io/gh/appleboy/drone-jenkins/branch/master/graph/badge.svg)](https://codecov.io/gh/appleboy/drone-jenkins)
[![Go Report Card](https://goreportcard.com/badge/github.com/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. 复制生成的令牌
![personal token](./images/personal-token.png)
或者,您可以使用在 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
View File
@@ -0,0 +1,412 @@
# drone-jenkins
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
![logo](./images/logo.png)
[![Lint and Testing](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
[![Trivy Security Scan](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml/badge.svg)](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
[![GoDoc](https://godoc.org/github.com/appleboy/drone-jenkins?status.svg)](https://godoc.org/github.com/appleboy/drone-jenkins)
[![codecov](https://codecov.io/gh/appleboy/drone-jenkins/branch/master/graph/badge.svg)](https://codecov.io/gh/appleboy/drone-jenkins)
[![Go Report Card](https://goreportcard.com/badge/github.com/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. 複製產生的令牌
![personal token](./images/personal-token.png)
或者,您可以使用在 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。
+7 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.20
FROM alpine:3.22
ARG TARGETOS
ARG TARGETARCH
@@ -15,6 +15,12 @@ LABEL org.opencontainers.image.licenses=MIT
RUN apk add --no-cache ca-certificates && \
rm -rf /var/cache/apk/*
RUN addgroup -g 1000 drone && \
adduser -D -u 1000 -G drone drone
COPY release/${TARGETOS}/${TARGETARCH}/drone-jenkins /bin/
RUN chown drone:drone /bin/drone-jenkins
USER drone
ENTRYPOINT ["/bin/drone-jenkins"]
+7 -4
View File
@@ -1,17 +1,20 @@
module github.com/appleboy/drone-jenkins
go 1.22
go 1.24.0
require (
github.com/appleboy/com v1.1.1
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.9.0
github.com/urfave/cli v1.22.15
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.7
github.com/yassinebenaid/godump v0.11.1
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
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-20250705151800-55b8f293f342 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+12 -18
View File
@@ -1,8 +1,7 @@
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -11,20 +10,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM=
github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0=
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.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.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+466 -25
View File
@@ -1,12 +1,22 @@
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"
)
type (
@@ -20,16 +30,152 @@ type (
Jenkins struct {
Auth *Auth
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"`
}
)
// 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
}
// 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(auth *Auth, url string) *Jenkins {
url = strings.TrimRight(url, "/")
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) {
@@ -44,43 +190,154 @@ 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 {
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
// 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
}
return http.DefaultClient.Do(req)
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) parseResponse(resp *http.Response, body interface{}) (err error) {
defer resp.Body.Close()
if body == nil {
return
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) get(
ctx context.Context,
path string,
params url.Values,
body interface{},
) error {
requestURL := jenkins.buildURL(path, params)
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
if err != nil {
return err
}
resp, err := jenkins.sendRequest(req, nil) // GET requests don't need crumb
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response code: %d, body: %s", resp.StatusCode, string(data))
}
if body == nil {
return nil
}
return json.Unmarshal(data, body)
}
func (jenkins *Jenkins) post(path string, params url.Values, body interface{}) (err error) {
// 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.NewRequest("POST", requestURL, nil)
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil)
if err != nil {
fmt.Println(err)
return
return 0, err
}
resp, err := jenkins.sendRequest(req)
resp, err := jenkins.sendRequest(req, crumb)
if err != nil {
fmt.Println(err)
return
return 0, err
}
return jenkins.parseResponse(resp, body)
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 {
@@ -100,8 +357,192 @@ func (jenkins *Jenkins) parseJobPath(job string) string {
return path
}
func (jenkins *Jenkins) trigger(job string, params url.Values) error {
path := jenkins.parseJobPath(job) + "/build"
// 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)
return jenkins.post(path, params, nil)
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)
}
var urlPath string
// Check if params contains build parameters (excluding 'token')
hasBuildParams := false
for key := range params {
if key != "token" {
hasBuildParams = true
break
}
}
if hasBuildParams {
urlPath = jenkins.parseJobPath(job) + "/buildWithParameters"
} else {
urlPath = jenkins.parseJobPath(job) + "/build"
}
// Debug: Display parameters being sent
if jenkins.Debug {
log.Println("=== Debug Mode: Jenkins Job Trigger ===")
log.Printf("Job: %s", job)
log.Printf("URL Path: %s", urlPath)
// Build the full URL for display
fullURL := jenkins.buildURL(urlPath, params)
// Mask token in URL for display
if jenkins.Token != "" {
fullURL = strings.Replace(fullURL, "token="+jenkins.Token, "token=***MASKED***", 1)
}
log.Printf("Full URL: %s", fullURL)
if len(params) > 0 {
// Create a copy of params with masked token for display
displayParams := url.Values{}
for key, values := range params {
if key == "token" {
// Mask token values for security
displayParams[key] = []string{"***MASKED***"}
} else {
displayParams[key] = values
}
}
log.Println("Parameters:")
if err := godump.Dump(displayParams); err != nil {
log.Printf("warning: failed to dump parameters: %v", err)
}
} else {
log.Println("Parameters: (none)")
}
log.Println("======================================")
}
// All params (including token) are passed as query parameters
// Returns the queue item ID for tracking
return jenkins.postAndGetLocation(ctx, urlPath, params)
}
+721 -6
View File
@@ -1,8 +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"
)
@@ -12,7 +19,16 @@ func TestParseJobPath(t *testing.T) {
Username: "appleboy",
Token: "1234",
}
jenkins := NewJenkins(auth, "http://example.com")
jenkins, err := NewJenkins(
context.Background(),
auth,
"http://example.com",
"",
false,
"",
false,
)
assert.NoError(t, err)
assert.Equal(t, "/job/foo", jenkins.parseJobPath("/foo/"))
assert.Equal(t, "/job/foo", jenkins.parseJobPath("foo/"))
@@ -25,19 +41,718 @@ func TestUnSupportProtocol(t *testing.T) {
Username: "foo",
Token: "bar",
}
jenkins := NewJenkins(auth, "example.com")
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) {
// Create a mock Jenkins server
var receivedParams url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedParams = r.URL.Query()
w.Header().Set("Location", "http://jenkins.example.com/queue/item/123/")
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
auth := &Auth{
Username: "foo",
Token: "bar",
}
jenkins := NewJenkins(auth, "http://example.com")
jenkins, err := NewJenkins(
context.Background(),
auth,
server.URL,
"remote-token",
false,
"",
false,
)
assert.NoError(t, err)
err := jenkins.trigger("drone-jenkins", url.Values{"token": []string{"bar"}})
assert.Nil(t, err)
params := url.Values{"param": []string{"value"}}
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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
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: "test-job",
buildNumber: 123,
responseBody: `{"number":123,"building":true,"duration":0,"result":null,` +
`"url":"http://jenkins.example.com/job/test-job/123/"}`,
responseStatus: http.StatusOK,
expectError: false,
expectBuilding: true,
expectResult: "",
},
{
name: "build completed successfully",
jobName: "test-job",
buildNumber: 124,
responseBody: `{"number":124,"building":false,"duration":5000,"result":"SUCCESS",` +
`"url":"http://jenkins.example.com/job/test-job/124/"}`,
responseStatus: http.StatusOK,
expectError: false,
expectBuilding: false,
expectResult: "SUCCESS",
},
{
name: "build failed",
jobName: "test-job",
buildNumber: 125,
responseBody: `{"number":125,"building":false,"duration":3000,"result":"FAILURE",` +
`"url":"http://jenkins.example.com/job/test-job/125/"}`,
responseStatus: http.StatusOK,
expectError: false,
expectBuilding: false,
expectResult: "FAILURE",
},
{
name: "build not found",
jobName: "test-job",
buildNumber: 999,
responseBody: "Not Found",
responseStatus: http.StatusNotFound,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Contains(t, r.URL.Path, "/job/")
w.WriteHeader(tt.responseStatus)
_, _ = w.Write([]byte(tt.responseBody))
}),
)
defer server.Close()
auth := &Auth{
Username: "test",
Token: "test",
}
jenkins, err := NewJenkins(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: "test",
Token: "test",
}
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
assert.NoError(t, err)
buildInfo, err := jenkins.waitForCompletion(
context.Background(),
"test-job",
queueID,
100*time.Millisecond,
5*time.Second,
)
assert.NoError(t, err)
assert.NotNil(t, buildInfo)
assert.Equal(t, buildNumber, buildInfo.Number)
assert.False(t, buildInfo.Building)
assert.Equal(t, "SUCCESS", buildInfo.Result)
})
t.Run("timeout waiting for queue", func(t *testing.T) {
queueID := 123
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Always return queue item without build number
w.WriteHeader(http.StatusOK)
_, _ = w.Write(
[]byte(`{"id":123,"blocked":false,"buildable":true,"why":"Waiting forever"}`),
)
}))
defer server.Close()
auth := &Auth{
Username: "test",
Token: "test",
}
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
assert.NoError(t, err)
buildInfo, err := jenkins.waitForCompletion(
context.Background(),
"test-job",
queueID,
50*time.Millisecond,
200*time.Millisecond,
)
assert.Error(t, err)
assert.Nil(t, buildInfo)
assert.Contains(t, err.Error(), "timeout")
})
t.Run("timeout waiting for build", func(t *testing.T) {
var callCount int32
queueID := 123
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&callCount, 1)
switch r.URL.Path {
case testQueueItemPath:
// Return build number immediately
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":123,"blocked":false,"buildable":true,` +
`"executable":{"number":456,"url":"http://example.com/job/test/456/"}}`))
case testBuildStatusPath:
// Always return building status
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"number":456,"building":true,"duration":0,"result":null}`))
}
_ = count
}))
defer server.Close()
auth := &Auth{
Username: "test",
Token: "test",
}
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
assert.NoError(t, err)
buildInfo, err := jenkins.waitForCompletion(
context.Background(),
"test-job",
queueID,
50*time.Millisecond,
200*time.Millisecond,
)
assert.Error(t, err)
assert.Nil(t, buildInfo)
assert.Contains(t, err.Error(), "timeout")
})
t.Run("build failed", func(t *testing.T) {
var callCount int32
queueID := 123
buildNumber := 456
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&callCount, 1)
switch r.URL.Path {
case testQueueItemPath:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":123,"blocked":false,"buildable":true,` +
`"executable":{"number":456,"url":"http://example.com/job/test/456/"}}`))
case testBuildStatusPath:
// First call: building, second call: failed
w.WriteHeader(http.StatusOK)
if count == 1 {
_, _ = w.Write(
[]byte(`{"number":456,"building":true,"duration":0,"result":null}`),
)
} else {
_, _ = w.Write([]byte(`{"number":456,"building":false,"duration":3000,"result":"FAILURE"}`))
}
}
}))
defer server.Close()
auth := &Auth{
Username: "test",
Token: "test",
}
jenkins, err := NewJenkins(context.Background(), auth, server.URL, "", false, "", false)
assert.NoError(t, err)
buildInfo, err := jenkins.waitForCompletion(
context.Background(),
"test-job",
queueID,
50*time.Millisecond,
5*time.Second,
)
assert.NoError(t, err)
assert.NotNil(t, buildInfo)
assert.Equal(t, buildNumber, buildInfo.Number)
assert.False(t, buildInfo.Building)
assert.Equal(t, "FAILURE", buildInfo.Result)
})
}
// Sample CA certificate for testing (self-signed, not for production use)
const testCACert = `-----BEGIN CERTIFICATE-----
MIIDAzCCAeugAwIBAgIUGYBGBr+t20UAWJorEPULxzGIXUEwDQYJKoZIhvcNAQEL
BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI1MTIwNjA1MDgzMloXDTM1MTIwNDA1
MDgzMlowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAq4bwnABqFenRVUoHLKhPiJXkh6TBFUaCWiEpKYNPywptBJNdyWNf
ouDxJ8gvQOMCkp3trnAHFcT6W5s8QLM1Hf/70QZI9GU/BtYm0KijU8aM+GJawNto
sK103TeCd0tVenDkxfamBGYnh3L5jtk0V/jeIsAIfFoe9Citu3MttRfxnSmZ4w2K
qlS14vKhFlO4WrXAh9j4PaVE5DL7jya/UKe6VVQIONCwUipRN6nU3UXhR7akVSmF
/bYkFsfdcErXJHjDpg+0xOsa5LJhzRkx5Uoqtviq2oRVVYhZc0eTwjq/407ocJ25
6WmerfKrtFDpzOZPa4XPVX9Am4vWugtrwQIDAQABo1MwUTAdBgNVHQ4EFgQUh7kL
LqmsvQP3TI6eiLVK7Gs7A00wHwYDVR0jBBgwFoAUh7kLLqmsvQP3TI6eiLVK7Gs7
A00wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEApLOdWacya+Zi
w0Fd3UfSveuRsayAkMkZ4p0L9XKlADzwKtSF1Ykn6wiEiYfXd2TvffsR2XglOXFc
181IpBhP5u2mzK6pRvH9mqTs3w8JTcXMFmg8AKE2Vg5k22tBM2OUJJgKXkiACuHS
pZeOOvJcnjGunbTRwqais0TLYnkOcFsbgrSBKv82HiVootH/iKZahf1ViFMOURTh
MqjwIous7Y53Rq4RmfycIjNwODlDW0i5atKe8incDBiIYKw6sH8WN+nuhnHC/vJ5
5ZQvGCUsGOvma5ojWAiLs8wu4dODuF5ZNID3t+M36PQs7JDaQNN+AkZROOTSMqa/
ud3vS1A5+g==
-----END CERTIFICATE-----`
func TestLoadCACert(t *testing.T) {
t.Run("empty string returns nil", func(t *testing.T) {
data, err := loadCACert(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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
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: "test",
Token: "test",
}
// 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: "test",
Token: "test",
}
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)
})
}
+172 -38
View File
@@ -1,31 +1,56 @@
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/joho/godotenv"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
"github.com/yassinebenaid/godump"
)
// Version set at compile-time
var Version string
var Version = "dev"
const asciiArt = `
________ ____. __ .__
\______ \_______ ____ ____ ____ | | ____ ____ | | _|__| ____ ______
| | \_ __ \/ _ \ / \_/ __ \ ______ | |/ __ \ / \| |/ / |/ \ / ___/
| | \ | \( <_> ) | \ ___/ /_____/ /\__| \ ___/| | \ <| | | \\___ \
/_______ /__| \____/|___| /\___ > \________|___ >___| /__|_ \__|___| /____ >
\/ \/ \/ \/ \/ \/ \/ \/
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 {
_ = godotenv.Load(filename)
if err := godotenv.Load(filename); err != nil && !os.IsNotExist(err) {
log.Printf("Warning: failed to load env file %s: %v", filename, err)
}
}
if _, err := os.Stat("/run/drone/env"); err == nil {
_ = godotenv.Overload("/run/drone/env")
if err := godotenv.Overload("/run/drone/env"); err != nil {
log.Printf("Warning: failed to load /run/drone/env: %v", err)
}
}
app := cli.NewApp()
app.Name = "jenkins plugin"
app.Usage = "trigger jenkins jobs"
app.Copyright = "Copyright (c) 2019 Bo-Yi Wu"
app.Authors = []cli.Author{
app.Authors = []*cli.Author{
{
Name: "Bo-Yi Wu",
Email: "appleboy.tw@gmail.com",
@@ -34,48 +59,93 @@ func main() {
app.Action = run
app.Version = Version
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "host",
Usage: "jenkins base url",
EnvVar: "PLUGIN_URL,JENKINS_URL,INPUT_URL",
&cli.StringFlag{
Name: "host",
Usage: "jenkins base url",
EnvVars: []string{"PLUGIN_URL", "JENKINS_URL", "INPUT_URL"},
},
cli.StringFlag{
Name: "user,u",
Usage: "jenkins username",
EnvVar: "PLUGIN_USER,JENKINS_USER,INPUT_USER",
&cli.StringFlag{
Name: "user",
Aliases: []string{"u"},
Usage: "jenkins username",
EnvVars: []string{"PLUGIN_USER", "JENKINS_USER", "INPUT_USER"},
},
cli.StringFlag{
Name: "token,t",
Usage: "jenkins token",
EnvVar: "PLUGIN_TOKEN,JENKINS_TOKEN,INPUT_TOKEN",
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "jenkins API token for authentication",
EnvVars: []string{"PLUGIN_TOKEN", "JENKINS_TOKEN", "INPUT_TOKEN"},
},
cli.StringSliceFlag{
Name: "job,j",
Usage: "jenkins job",
EnvVar: "PLUGIN_JOB,JENKINS_JOB,INPUT_JOB",
&cli.StringFlag{
Name: "remote-token",
Usage: "jenkins remote trigger token",
EnvVars: []string{"PLUGIN_REMOTE_TOKEN", "JENKINS_REMOTE_TOKEN", "INPUT_REMOTE_TOKEN"},
},
&cli.StringSliceFlag{
Name: "job",
Aliases: []string{"j"},
Usage: "jenkins job",
EnvVars: []string{"PLUGIN_JOB", "JENKINS_JOB", "INPUT_JOB"},
},
&cli.BoolFlag{
Name: "insecure",
Usage: "allow insecure server connections when using SSL",
EnvVars: []string{"PLUGIN_INSECURE", "JENKINS_INSECURE", "INPUT_INSECURE"},
},
&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 (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
cli.AppHelpTemplate = `
________ ____. __ .__
\______ \_______ ____ ____ ____ | | ____ ____ | | _|__| ____ ______
| | \_ __ \/ _ \ / \_/ __ \ ______ | |/ __ \ / \| |/ / |/ \ / ___/
| | \ | \( <_> ) | \ ___/ /_____/ /\__| \ ___/| | \ <| | | \\___ \
/_______ /__| \____/|___| /\___ > \________|\___ >___| /__|_ \__|___| /____ >
\/ \/ \/ \/ \/ \/ \/ \/
version: {{.Version}}
cli.AppHelpTemplate = asciiArt + `
NAME:
{{.Name}} - {{.Usage}}
USAGE:
{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}
{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} ` +
`{{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}
{{if len .Authors}}
AUTHOR:
{{range .Authors}}{{ . }}{{end}}
{{end}}{{if .Commands}}
COMMANDS:
{{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}{{end}}{{if .VisibleFlags}}
{{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}{{end}}` +
`{{if .VisibleFlags}}
GLOBAL OPTIONS:
{{range .VisibleFlags}}{{.}}
{{end}}{{end}}{{if .Copyright }}
@@ -86,7 +156,7 @@ VERSION:
{{.Version}}
{{end}}
REPOSITORY:
Github: https://github.com/appleboy/drone-line
Github: https://github.com/appleboy/drone-jenkins
`
if err := app.Run(os.Args); err != nil {
@@ -95,12 +165,76 @@ REPOSITORY:
}
func run(c *cli.Context) error {
plugin := Plugin{
BaseURL: c.String("host"),
Username: c.String("user"),
Token: c.String("token"),
Job: c.StringSlice("job"),
// Validate required parameters
if c.String("host") == "" {
return fmt.Errorf("host is required")
}
return plugin.Exec()
if len(c.StringSlice("job")) == 0 {
return fmt.Errorf("at least one job is required")
}
// Validate authentication: either (user + token) or remote-token must be provided
hasUserAuth := c.String("user") != "" && c.String("token") != ""
hasRemoteToken := c.String("remote-token") != ""
if !hasUserAuth && !hasRemoteToken {
return fmt.Errorf("authentication required: provide either (user + token) or remote-token")
}
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"),
CACert: c.String("ca-cert"),
Parameters: c.String("parameters"),
Wait: c.Bool("wait"),
PollInterval: c.Duration("poll-interval"),
Timeout: c.Duration("timeout"),
Debug: c.Bool("debug"),
}
// Display plugin configuration in debug mode
if plugin.Debug {
log.Println("=== Debug Mode: Plugin Configuration ===")
// Create a display copy with masked sensitive data
displayPlugin := struct {
BaseURL string
Username string
Token string
RemoteToken string
Job []string
Insecure bool
CACert string
Parameters string
Wait bool
PollInterval time.Duration
Timeout time.Duration
Debug bool
}{
BaseURL: plugin.BaseURL,
Username: plugin.Username,
Token: maskToken(plugin.Token),
RemoteToken: maskToken(plugin.RemoteToken),
Job: plugin.Job,
Insecure: plugin.Insecure,
CACert: plugin.CACert,
Parameters: plugin.Parameters,
Wait: plugin.Wait,
PollInterval: plugin.PollInterval,
Timeout: plugin.Timeout,
Debug: plugin.Debug,
}
if err := godump.Dump(displayPlugin); err != nil {
log.Printf("warning: failed to dump plugin configuration: %v", err)
}
log.Println("========================================")
}
return plugin.Exec(c.Context)
}
+152 -26
View File
@@ -1,56 +1,182 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"strings"
"time"
)
type (
// Plugin values.
// Plugin represents the configuration for the Jenkins plugin.
// It contains all necessary credentials and settings to trigger Jenkins jobs.
Plugin struct {
BaseURL string
Username string
Token string
Job []string
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
}
)
func trimElement(keys []string) []string {
newKeys := []string{}
// trimWhitespaceFromSlice removes empty and whitespace-only strings from a slice.
// It returns a new slice containing only non-empty trimmed strings.
func trimWhitespaceFromSlice(items []string) []string {
result := make([]string, 0, len(items))
for _, value := range keys {
value = strings.Trim(value, " ")
if len(value) == 0 {
continue
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
result = append(result, trimmed)
}
newKeys = append(newKeys, value)
}
return newKeys
return result
}
// Exec executes the plugin.
func (p Plugin) Exec() error {
if len(p.BaseURL) == 0 || len(p.Username) == 0 || len(p.Token) == 0 {
return errors.New("missing jenkins config")
// 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 {
values := url.Values{}
// 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",
trimmedLine,
)
continue
}
key := strings.TrimSpace(parts[0])
value := parts[1] // Keep value as-is to preserve intentional spaces
if key == "" {
log.Printf("warning: skipping parameter with empty key: %q", trimmedLine)
continue
}
values.Add(key, value)
}
jobs := trimElement(p.Job)
return values
}
// validateConfig checks that all required plugin configuration is present.
// It returns a descriptive error if any required field is missing.
func (p Plugin) validateConfig() error {
if p.BaseURL == "" {
return errors.New("jenkins base URL 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.
// 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)
}
// Clean and validate job list
jobs := trimWhitespaceFromSlice(p.Job)
if len(jobs) == 0 {
return errors.New("missing jenkins job")
return errors.New("at least one Jenkins job name is required")
}
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,
}
}
jenkins := NewJenkins(auth, p.BaseURL)
// Initialize Jenkins client
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)
}
for _, v := range jobs {
if err := jenkins.trigger(v, nil); err != nil {
return 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 {
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 (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)
}
}
+474 -28
View File
@@ -1,68 +1,514 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMissingConfig(t *testing.T) {
var plugin Plugin
const (
testJobBuildPath = "/job/test-job/build"
testQueueItemPath = "/queue/item/123/api/json"
testBuildStatusPath = "/job/test-job/456/api/json"
)
err := plugin.Exec()
// TestValidateConfig tests the validateConfig method
func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
plugin Plugin
wantError bool
errorMsg string
}{
{
name: "missing all config",
plugin: Plugin{},
wantError: true,
errorMsg: "jenkins base URL is required",
},
{
name: "missing authentication",
plugin: Plugin{
BaseURL: "http://example.com",
},
wantError: true,
errorMsg: "authentication required",
},
{
name: "missing token (only username)",
plugin: Plugin{
BaseURL: "http://example.com",
Username: "foo",
},
wantError: true,
errorMsg: "authentication required",
},
{
name: "missing username (only token)",
plugin: Plugin{
BaseURL: "http://example.com",
Token: "bar",
},
wantError: true,
errorMsg: "authentication required",
},
{
name: "user and token auth",
plugin: Plugin{
BaseURL: "http://example.com",
Username: "foo",
Token: "bar",
},
wantError: false,
},
{
name: "remote token auth",
plugin: Plugin{
BaseURL: "http://example.com",
RemoteToken: "remote-token-123",
},
wantError: false,
},
{
name: "both auth methods",
plugin: Plugin{
BaseURL: "http://example.com",
Username: "foo",
Token: "bar",
RemoteToken: "remote-token-123",
},
wantError: false,
},
}
assert.NotNil(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.plugin.validateConfig()
if tt.wantError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestMissingJenkinsConfig(t *testing.T) {
// TestTrimWhitespaceFromSlice tests the trimWhitespaceFromSlice function
func TestTrimWhitespaceFromSlice(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "remove empty and whitespace strings",
input: []string{"1", " ", "3"},
expected: []string{"1", "3"},
},
{
name: "no whitespace strings",
input: []string{"1", "2"},
expected: []string{"1", "2"},
},
{
name: "all whitespace",
input: []string{" ", "\t", "\n"},
expected: []string{},
},
{
name: "empty slice",
input: []string{},
expected: []string{},
},
{
name: "trim surrounding whitespace",
input: []string{" foo ", " bar ", "baz"},
expected: []string{"foo", "bar", "baz"},
},
{
name: "mixed empty and valid",
input: []string{"", "valid", "", "also-valid", ""},
expected: []string{"valid", "also-valid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trimWhitespaceFromSlice(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestParseParameters tests the parseParameters function
func TestParseParameters(t *testing.T) {
tests := []struct {
name string
input string
expected url.Values
}{
{
name: "valid parameters",
input: "key1=value1\nkey2=value2",
expected: url.Values{
"key1": []string{"value1"},
"key2": []string{"value2"},
},
},
{
name: "parameter with multiple equals signs",
input: "key=value=with=equals",
expected: url.Values{
"key": []string{"value=with=equals"},
},
},
{
name: "parameter with spaces in value",
input: "key=value with spaces",
expected: url.Values{
"key": []string{"value with spaces"},
},
},
{
name: "parameter with empty value",
input: "key=",
expected: url.Values{
"key": []string{""},
},
},
{
name: "invalid parameter format (no equals)",
input: "invalid",
expected: url.Values{},
},
{
name: "parameter with empty key",
input: "=value",
expected: url.Values{},
},
{
name: "mixed valid and invalid",
input: "valid=yes\ninvalid\nalso=valid",
expected: url.Values{
"valid": []string{"yes"},
"also": []string{"valid"},
},
},
{
name: "key with surrounding whitespace",
input: " key =value",
expected: url.Values{
"key": []string{"value"},
},
},
{
name: "empty string",
input: "",
expected: url.Values{},
},
{
name: "multiple empty lines",
input: "key1=value1\n\n\nkey2=value2",
expected: url.Values{
"key1": []string{"value1"},
"key2": []string{"value2"},
},
},
{
name: "lines with whitespace only",
input: "key1=value1\n \n\t\nkey2=value2",
expected: url.Values{
"key1": []string{"value1"},
"key2": []string{"value2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseParameters(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestExecMissingConfig tests Exec with missing configuration
func TestExecMissingConfig(t *testing.T) {
var plugin Plugin
err := plugin.Exec(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration error")
assert.Contains(t, err.Error(), "jenkins base URL is required")
}
// TestExecMissingJenkinsUsername tests Exec with missing username
func TestExecMissingJenkinsUsername(t *testing.T) {
plugin := Plugin{
BaseURL: "http://example.com",
}
err := plugin.Exec()
err := plugin.Exec(context.Background())
assert.NotNil(t, err)
assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration error")
assert.Contains(t, err.Error(), "authentication required")
}
func TestMissingJenkinsJob(t *testing.T) {
// TestExecMissingJenkinsToken tests Exec with missing token
func TestExecMissingJenkinsToken(t *testing.T) {
plugin := Plugin{
BaseURL: "http://example.com",
Username: "foo",
Token: "bar",
}
err := plugin.Exec()
assert.NotNil(t, err)
err := plugin.Exec(context.Background())
plugin.Job = []string{" "}
err = plugin.Exec()
assert.NotNil(t, err)
assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration error")
assert.Contains(t, err.Error(), "authentication required")
}
func TestPluginTriggerBuild(t *testing.T) {
// TestExecMissingJenkinsJob tests Exec with missing or empty job list
func TestExecMissingJenkinsJob(t *testing.T) {
tests := []struct {
name string
jobs []string
}{
{
name: "no jobs",
jobs: []string{},
},
{
name: "only whitespace jobs",
jobs: []string{" ", "\t", "\n"},
},
{
name: "nil jobs",
jobs: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugin := Plugin{
BaseURL: "http://example.com",
Username: "foo",
Token: "bar",
Job: tt.jobs,
}
err := plugin.Exec(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least one Jenkins job name is required")
})
}
}
// 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.Header().
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", queueID))
w.WriteHeader(http.StatusCreated)
queueID++
}))
defer server.Close()
plugin := Plugin{
BaseURL: "http://example.com",
BaseURL: server.URL,
Username: "foo",
Token: "bar",
Job: []string{"drone-jenkins"},
}
err := plugin.Exec()
err := plugin.Exec(context.Background())
assert.Nil(t, err)
assert.NoError(t, err)
}
func TestTrimElement(t *testing.T) {
var input, result []string
// TestExecTriggerMultipleJobs tests triggering multiple jobs
func TestExecTriggerMultipleJobs(t *testing.T) {
// Create a mock Jenkins server
jobsTriggered := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 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()
input = []string{"1", " ", "3"}
result = []string{"1", "3"}
plugin := Plugin{
BaseURL: server.URL,
Username: "foo",
Token: "bar",
Job: []string{"job1", "job2", "job3"},
}
assert.Equal(t, result, trimElement(input))
err := plugin.Exec(context.Background())
input = []string{"1", "2"}
result = []string{"1", "2"}
assert.Equal(t, result, trimElement(input))
assert.NoError(t, err)
assert.Equal(t, 3, jobsTriggered)
}
// TestExecWithParameters tests job triggering with parameters
func TestExecWithParameters(t *testing.T) {
// Create a mock Jenkins server
var receivedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.Query()
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",
Job: []string{"parameterized-job"},
Parameters: "branch=main\nenvironment=production",
}
err := plugin.Exec(context.Background())
assert.NoError(t, err)
assert.Equal(t, "main", receivedQuery.Get("branch"))
assert.Equal(t, "production", receivedQuery.Get("environment"))
}
// TestExecWithRemoteToken tests job triggering with remote token
func TestExecWithRemoteToken(t *testing.T) {
// Create a mock Jenkins server
var receivedToken string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedToken = r.URL.Query().Get("token")
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",
Job: []string{"secure-job"},
}
err := plugin.Exec(context.Background())
assert.NoError(t, err)
assert.Equal(t, "remote-token-123", receivedToken)
}
// TestExecWithJobsContainingWhitespace tests job list with whitespace
func TestExecWithJobsContainingWhitespace(t *testing.T) {
// Create a mock Jenkins server
jobsTriggered := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 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"},
}
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: "foo",
Token: "bar",
Job: []string{"test-job"},
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: "foo",
Token: "bar",
Job: []string{"test-job"},
Wait: true,
}
err := plugin.Exec(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed with status: FAILURE")
}