mirror of
https://github.com/appleboy/drone-jenkins.git
synced 2026-06-16 14:49:16 +08:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2a83d3d6c | |||
| 351ac33e2d | |||
| 984ca01afc | |||
| d30890d257 | |||
| 3309475595 | |||
| 2b5c542196 | |||
| d8cceb3839 | |||
| 6a8250d7e6 | |||
| 8dfbb92314 | |||
| b0761290c2 | |||
| 02829360ad | |||
| f3a67c62a6 | |||
| 069e6455cc | |||
| eb51e55e81 | |||
| da87ddb86b | |||
| 60874908e6 | |||
| f6e62d9c49 | |||
| cf9b9a0a0d | |||
| 4c54d13899 | |||
| 627e233cc6 | |||
| 747d7b23d1 | |||
| 48d5425edd | |||
| cda84c122e | |||
| afbea8106e | |||
| cc67514a1a | |||
| 3d7ffaed68 | |||
| 52abef124e | |||
| 3dd86f956c | |||
| a5469c939e | |||
| a6d967789d | |||
| 3eb2242053 | |||
| 908be474f3 | |||
| de494cbda1 | |||
| 77ea4873e0 | |||
| 36013b246a | |||
| cbfb2bb51b | |||
| b6589abef3 | |||
| 626b9e4bfa | |||
| e60e4b9161 | |||
| c213337305 | |||
| a7c6b81621 | |||
| bb1e9fe4e0 | |||
| 1bb020b22d | |||
| 24e8004b58 | |||
| dff06f164a | |||
| 895becb9a7 | |||
| 5344ca212b | |||
| b370179bda | |||
| d9f9a4ddc5 | |||
| 51db9357f0 | |||
| 0326a42ccb | |||
| 4f822500f2 | |||
| f932a0ef24 | |||
| 5122d14617 | |||
| 65d16c52a3 | |||
| 86574d9456 | |||
| 1cbab46f5c | |||
| 7c4ff53a64 | |||
| 4d6399606b | |||
| 118b8f9b35 | |||
| daf0401e30 | |||
| 685cc312e3 | |||
| cac1337e91 | |||
| 742e20ea9c | |||
| 6ca333982b | |||
| 5e1b16bcf5 | |||
| 7b357075fe | |||
| bd482e9c7a | |||
| 0d110e195c | |||
| 7441ec1d9b | |||
| ee6e15d448 | |||
| e8184fbb5c | |||
| 326b4c2dc5 | |||
| e70b95f505 | |||
| beee0b43a6 | |||
| 93c81b3ee6 | |||
| 9e55d9c14f | |||
| e67398a6a3 | |||
| ded8530d8e | |||
| eac91dc0af | |||
| c81cb4142a | |||
| ead7714d9a | |||
| 96b11d47cd | |||
| 3362d46d26 | |||
| bba1fb3a6e | |||
| 0af129e9d8 | |||
| 6777964976 | |||
| 9eca070b01 | |||
| 4d3054d832 | |||
| b007a60843 | |||
| 18b163fd7b | |||
| 03bcfacb96 | |||
| 042c5d632c | |||
| fc238fb606 | |||
| bc987b6d01 | |||
| 6e58cda4fe | |||
| 396719ff0f | |||
| 8e578266db | |||
| 7d040a545c | |||
| e07592691c | |||
| 7ba6170e12 | |||
| 4ce3a1e347 | |||
| 1898550d88 | |||
| 6ef9ab8751 | |||
| 7275c0c66b | |||
| bb23a33bf0 | |||
| c4cd67adc5 | |||
| 788418c56f | |||
| 2211e06f29 | |||
| 226713ff24 | |||
| 186ee76d3c | |||
| 4917613ed8 | |||
| e6e2562e1e |
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!release/
|
||||
@@ -1,42 +0,0 @@
|
||||
# unifying the coding style for different editors and IDEs => editorconfig.org
|
||||
|
||||
; indicate this is the root of the project
|
||||
root = true
|
||||
|
||||
###########################################################
|
||||
; common
|
||||
###########################################################
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
|
||||
end_of_line = LF
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
###########################################################
|
||||
; make
|
||||
###########################################################
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[makefile]
|
||||
indent_style = tab
|
||||
|
||||
###########################################################
|
||||
; markdown
|
||||
###########################################################
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
###########################################################
|
||||
; golang
|
||||
###########################################################
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: ['https://www.paypal.me/appleboy46']
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,54 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: "41 23 * * 6"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["go"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
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.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
make build_linux_amd64
|
||||
make build_linux_arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: docker/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Goreleaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Lint and Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "stable"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.6
|
||||
args: --verbose
|
||||
|
||||
- uses: hadolint/hadolint-action@v3.3.0
|
||||
name: hadolint for Dockerfile
|
||||
with:
|
||||
dockerfile: docker/Dockerfile
|
||||
|
||||
testing:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
go: ["1.25"]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
go-build: ~/.cache/go-build
|
||||
name: ${{ matrix.os }} @ Go ${{ matrix.go }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
GO111MODULE: on
|
||||
GOPROXY: https://proxy.golang.org
|
||||
steps:
|
||||
- name: Set up Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.go-build }}
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Run Tests
|
||||
run: |
|
||||
go test -race -cover -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: ${{ matrix.os }},go-${{ matrix.go }}
|
||||
@@ -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.33.1
|
||||
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.33.1
|
||||
if: always()
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
scanners: "vuln,secret,misconfig"
|
||||
format: "table"
|
||||
severity: "CRITICAL,HIGH,MEDIUM"
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
+2
-1
@@ -22,6 +22,7 @@ _testmain.go
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
vendor
|
||||
drone-jenkins
|
||||
coverage.txt
|
||||
.env
|
||||
dist
|
||||
|
||||
@@ -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$
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
ignored:
|
||||
- DL3018
|
||||
- DL3008
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
sudo: required
|
||||
language: go
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
go:
|
||||
- 1.5.4
|
||||
- 1.6.3
|
||||
- 1.7.1
|
||||
- tip
|
||||
|
||||
env:
|
||||
global:
|
||||
- DOCKER_CACHE_FILE=${HOME}/docker/cache.tar.gz
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- vendor
|
||||
- ${HOME}/.glide
|
||||
- ${HOME}/docker
|
||||
|
||||
before_install:
|
||||
- mkdir -p $GOPATH/bin
|
||||
- curl https://glide.sh/get | sh
|
||||
- if [ -f ${DOCKER_CACHE_FILE} ]; then gunzip -c ${DOCKER_CACHE_FILE} | docker load; fi
|
||||
|
||||
install:
|
||||
- export GO15VENDOREXPERIMENT=1
|
||||
- make install
|
||||
|
||||
script:
|
||||
- make test
|
||||
- make docker
|
||||
- if [ "$TRAVIS_BRANCH" == "master" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
|
||||
mkdir -p $(dirname ${DOCKER_CACHE_FILE});
|
||||
docker save $(docker history -q $TRAVIS_REPO_SLUG:latest | grep -v '<missing>') | gzip > ${DOCKER_CACHE_FILE};
|
||||
fi
|
||||
|
||||
after_success:
|
||||
# ignore main.go coverage
|
||||
- sed -i '/main.go/d' coverage.txt
|
||||
- bash <(curl -s https://codecov.io/bash) -f coverage.txt
|
||||
# deploy from master
|
||||
- if [ "$TRAVIS_BRANCH" == "master" ] && [ "$TRAVIS_GO_VERSION" == "1.7.1" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
|
||||
docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD";
|
||||
make docker_deploy tag=latest;
|
||||
fi
|
||||
# deploy from tag
|
||||
- if [ "$TRAVIS_GO_VERSION" == "1.7.1" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_TAG" != "" ]; then
|
||||
docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD";
|
||||
make docker_deploy tag=$TRAVIS_TAG;
|
||||
fi
|
||||
@@ -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.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
date: 2019-10-19T00:00:00+00:00
|
||||
title: Jenkins
|
||||
author: appleboy
|
||||
tags: [ infrastructure, trigger, jenkins ]
|
||||
repo: appleboy/drone-jenkins
|
||||
logo: jenkins.svg
|
||||
image: appleboy/drone-jenkins
|
||||
---
|
||||
|
||||
The Jenkins plugin allows you to trigger Jenkins job automatically. The below pipeline configuration demonstrates simple usage:
|
||||
|
||||
```yaml
|
||||
- name: trigger jenkins job
|
||||
image: appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://example.com
|
||||
user: appleboy
|
||||
token: xxxxxxxxxx
|
||||
job: drone-jenkins-plugin-job
|
||||
```
|
||||
|
||||
Example configuration with multiple jobs:
|
||||
|
||||
```diff
|
||||
- name: trigger jenkins job
|
||||
image: appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://example.com
|
||||
user: appleboy
|
||||
token: xxxxxxxxxx
|
||||
job:
|
||||
+ - drone-jenkins-plugin-job-1
|
||||
+ - drone-jenkins-plugin-job-2
|
||||
```
|
||||
|
||||
Example configuration with jobs in the folder:
|
||||
|
||||
```diff
|
||||
- name: trigger jenkins job
|
||||
image: appleboy/drone-jenkins
|
||||
settings:
|
||||
url: http://example.com
|
||||
user: appleboy
|
||||
token: xxxxxxxxxx
|
||||
+ job: folder_name/job_name
|
||||
```
|
||||
|
||||
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
|
||||
: jenkins server base url.
|
||||
|
||||
user
|
||||
: jenkins user account
|
||||
|
||||
token
|
||||
: jenkins user 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)
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM centurylink/ca-certs
|
||||
|
||||
ADD drone-jenkins /
|
||||
|
||||
ENTRYPOINT ["/drone-jenkins"]
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM armhfbuild/alpine:3.4
|
||||
|
||||
RUN apk update && \
|
||||
apk add ca-certificates && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
ADD drone-jenkins /bin/
|
||||
ENTRYPOINT ["/bin/drone-jenkins"]
|
||||
@@ -1,8 +1,9 @@
|
||||
.PHONY: install build test html update docker_build docker_image docker docker_deploy clean
|
||||
|
||||
VERSION := $(shell git describe --tags || git rev-parse --short HEAD)
|
||||
DEPLOY_ACCOUNT := "appleboy"
|
||||
DEPLOY_IMAGE := "drone-jenkins"
|
||||
DIST := dist
|
||||
EXECUTABLE := drone-jenkins
|
||||
GOFMT ?= gofumpt -l -s -w
|
||||
GO ?= go
|
||||
GOFILES := $(shell find . -name "*.go" -type f)
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
|
||||
ifneq ($(shell uname), Darwin)
|
||||
EXTLDFLAGS = -extldflags "-static" $(null)
|
||||
@@ -10,36 +11,84 @@ else
|
||||
EXTLDFLAGS =
|
||||
endif
|
||||
|
||||
install:
|
||||
glide install
|
||||
ifeq ($(HAS_GO), GO)
|
||||
GOPATH ?= $(shell $(GO) env GOPATH)
|
||||
export PATH := $(GOPATH)/bin:$(PATH)
|
||||
|
||||
build:
|
||||
go build -ldflags="$(EXTLDFLAGS)-s -w -X main.Version=$(VERSION)"
|
||||
|
||||
test:
|
||||
go test -v -coverprofile=coverage.txt
|
||||
|
||||
html:
|
||||
go tool cover -html=coverage.txt
|
||||
|
||||
update:
|
||||
glide up
|
||||
|
||||
docker_build:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -tags netgo -ldflags="-X main.Version=$(VERSION)"
|
||||
|
||||
docker_image:
|
||||
docker build --rm -t $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE) .
|
||||
|
||||
docker: docker_build docker_image
|
||||
|
||||
docker_deploy:
|
||||
ifeq ($(tag),)
|
||||
@echo "Usage: make $@ tag=<tag>"
|
||||
@exit 1
|
||||
CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766
|
||||
CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS)
|
||||
endif
|
||||
docker tag $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):latest $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):$(tag)
|
||||
docker push $(DEPLOY_ACCOUNT)/$(DEPLOY_IMAGE):$(tag)
|
||||
|
||||
clean:
|
||||
rm -rf coverage.txt ${DEPLOY_IMAGE}
|
||||
ifeq ($(OS), Windows_NT)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
else ifeq ($(OS), Windows)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
else
|
||||
GOFLAGS := -v
|
||||
EXECUTABLE ?= $(EXECUTABLE)
|
||||
endif
|
||||
|
||||
ifneq ($(DRONE_TAG),)
|
||||
VERSION ?= $(DRONE_TAG)
|
||||
else
|
||||
VERSION ?= $(shell git describe --tags --always || git rev-parse --short HEAD)
|
||||
endif
|
||||
|
||||
TAGS ?=
|
||||
LDFLAGS ?= -X 'main.Version=$(VERSION)'
|
||||
|
||||
.PHONY: all
|
||||
all: build ## Build the project (default target)
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run tests with coverage
|
||||
@$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run golangci-lint
|
||||
golangci-lint run -v
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format code with golangci-lint
|
||||
golangci-lint fmt
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## Install 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/$@ .
|
||||
|
||||
.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) .
|
||||
|
||||
.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) .
|
||||
|
||||
.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) .
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
$(GO) clean -x -i ./...
|
||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
||||
|
||||
.PHONY: help
|
||||
help: ## Print this help message.
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: version
|
||||
version: ## Print current version
|
||||
@echo $(VERSION)
|
||||
|
||||
@@ -1,60 +1,538 @@
|
||||
# drone-jenkins
|
||||
|
||||
[](https://travis-ci.org/appleboy/drone-jenkins) [](https://codecov.io/gh/appleboy/drone-jenkins) [](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||
[Drone](https://github.com/drone/drone) plugin for trigger [Jenkins](https://jenkins.io/) jobs.
|
||||

|
||||
|
||||
## Build
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/lint.yml)
|
||||
[](https://github.com/appleboy/drone-jenkins/actions/workflows/trivy.yml)
|
||||
[](https://godoc.org/github.com/appleboy/drone-jenkins)
|
||||
[](https://codecov.io/gh/appleboy/drone-jenkins)
|
||||
[](https://goreportcard.com/report/github.com/appleboy/drone-jenkins)
|
||||
|
||||
Build the binary with the following commands:
|
||||
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.
|
||||
|
||||
```
|
||||
$ make build
|
||||
## Why drone-jenkins?
|
||||
|
||||
In modern enterprise environments, teams often adopt different CI/CD platforms based on their specific needs, project requirements, or historical decisions. It's common to find:
|
||||
|
||||
- **Multiple CI platforms coexisting**: Some teams use Jenkins for its extensive plugin ecosystem, while others prefer GitHub Actions or GitLab CI for their simplicity and container-native approach.
|
||||
- **Legacy systems integration**: Organizations with established Jenkins pipelines need to integrate with newer CI/CD workflows without rewriting everything.
|
||||
- **Cross-team collaboration**: Different departments may standardize on different tools, requiring seamless communication between platforms.
|
||||
|
||||
**drone-jenkins** bridges this gap by allowing CI/CD pipelines to trigger Jenkins jobs as part of their workflow. It works seamlessly with **GitHub Actions**, **GitLab CI**, **Gitea Action**, and any CI platform that supports Docker containers or shell commands.
|
||||
|
||||
This enables:
|
||||
|
||||
- **Unified deployment pipelines**: Trigger existing Jenkins deployment jobs from any CI platform without migration
|
||||
- **Gradual migration**: Teams can incrementally move to modern CI platforms while still leveraging Jenkins jobs
|
||||
- **Best of both worlds**: Use GitHub Actions or GitLab CI for modern containerized builds and Jenkins for specialized tasks with specific plugins
|
||||
- **Centralized orchestration**: Coordinate builds across multiple CI systems from a single pipeline
|
||||
- **Flexibility**: Available as a CLI binary or Docker image—use it however fits your workflow
|
||||
|
||||
Whether you're managing a hybrid CI/CD environment or orchestrating complex multi-platform deployments, drone-jenkins provides the connectivity you need.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [drone-jenkins](#drone-jenkins)
|
||||
- [Why drone-jenkins?](#why-drone-jenkins)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [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
|
||||
go install github.com/appleboy/drone-jenkins@latest
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Build from Source
|
||||
|
||||
Test the package with the following command:
|
||||
Clone the repository and build:
|
||||
|
||||
```
|
||||
$ make test
|
||||
```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:
|
||||
|
||||
```
|
||||
$ make docker
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
Please note incorrectly building the image for the correct x64 linux and with
|
||||
GCO disabled will result in an error when running the Docker image:
|
||||
Or pull the pre-built image:
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
docker: Error response from daemon: Container command
|
||||
'/bin/drone-jenkins' not found or does not exist..
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||
Execute from the working directory:
|
||||
### Command Line
|
||||
|
||||
**Single job:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin
|
||||
```
|
||||
|
||||
**Multiple jobs:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin-1 \
|
||||
--job drone-jenkins-plugin-2
|
||||
```
|
||||
|
||||
**With build parameters:**
|
||||
|
||||
```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 PLUGIN_BASE_URL=http://example.com \
|
||||
-e PLUGIN_USERNAME=xxxxxxx \
|
||||
-e PLUGIN_TOKEN=xxxxxxx \
|
||||
-e PLUGIN_JOB=xxxxxxx \
|
||||
-e DRONE_REPO_OWNER=appleboy \
|
||||
-e DRONE_REPO_NAME=go-hello \
|
||||
-e DRONE_COMMIT_SHA=e5e82b5eb3737205c25955dcc3dcacc839b7be52 \
|
||||
-e DRONE_COMMIT_BRANCH=master \
|
||||
-e DRONE_COMMIT_AUTHOR=appleboy \
|
||||
-e DRONE_BUILD_NUMBER=1 \
|
||||
-e DRONE_BUILD_STATUS=success \
|
||||
-e DRONE_BUILD_LINK=http://github.com/appleboy/go-hello \
|
||||
-v $(pwd):$(pwd) \
|
||||
-w $(pwd) \
|
||||
appleboy/drone-jenkins
|
||||
-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
|
||||
```
|
||||
|
||||
**Multiple jobs:**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**With build parameters:**
|
||||
|
||||
```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
|
||||
make build
|
||||
```
|
||||
|
||||
Build the Docker image:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
### 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
@@ -0,0 +1,412 @@
|
||||
# drone-jenkins
|
||||
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||

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

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

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

|
||||
|
||||
或者,您可以使用在 Jenkins 任務設定中配置的遠端觸發令牌。
|
||||
|
||||
#### CSRF 保護注意事項
|
||||
|
||||
現代 Jenkins 安裝預設啟用 CSRF 保護。如果您遇到以下錯誤:
|
||||
|
||||
```
|
||||
Error 403 No valid crumb was included in the request
|
||||
```
|
||||
|
||||
這表示您的 Jenkins 已啟用 CSRF 保護。您**必須**使用 API 令牌認證(user + token)。單獨使用遠端觸發令牌將無法運作。
|
||||
|
||||
如需更多關於 Jenkins CSRF 保護的資訊,請參閱 [Jenkins 官方文件](https://www.jenkins.io/doc/book/security/csrf-protection/)。
|
||||
|
||||
### 參數參考
|
||||
|
||||
| 參數 | CLI 旗標 | 環境變數 | 必要 | 說明 |
|
||||
| ------------- | -------------------- | ----------------------------------------------- | ------------- | ------------------------------------------------------------------------- |
|
||||
| Host | `--host` | `PLUGIN_URL`, `JENKINS_URL` | 是 | Jenkins 基礎 URL(例如 `http://jenkins.example.com/`) |
|
||||
| User | `--user`, `-u` | `PLUGIN_USER`, `JENKINS_USER` | 條件式\* | Jenkins 使用者名稱 |
|
||||
| Token | `--token`, `-t` | `PLUGIN_TOKEN`, `JENKINS_TOKEN` | 條件式\* | Jenkins API 令牌 |
|
||||
| Remote Token | `--remote-token` | `PLUGIN_REMOTE_TOKEN`, `JENKINS_REMOTE_TOKEN` | 條件式\* | Jenkins 遠端觸發令牌 |
|
||||
| Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | 是 | Jenkins 任務名稱 - 可指定多個 |
|
||||
| Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | 否 | 建置參數,多行 `key=value` 格式(每行一個) |
|
||||
| Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | 否 | 允許不安全的 SSL 連線(預設:false) |
|
||||
| CA Cert | `--ca-cert` | `PLUGIN_CA_CERT`, `JENKINS_CA_CERT` | 否 | 自訂 CA 憑證(PEM 內容、檔案路徑或 HTTP URL) |
|
||||
| Wait | `--wait` | `PLUGIN_WAIT`, `JENKINS_WAIT` | 否 | 等待任務完成(預設:false) |
|
||||
| Poll Interval | `--poll-interval` | `PLUGIN_POLL_INTERVAL`, `JENKINS_POLL_INTERVAL` | 否 | 狀態檢查間隔(預設:10s) |
|
||||
| Timeout | `--timeout` | `PLUGIN_TIMEOUT`, `JENKINS_TIMEOUT` | 否 | 等待任務完成的最長時間(預設:30m) |
|
||||
| Debug | `--debug` | `PLUGIN_DEBUG`, `JENKINS_DEBUG` | 否 | 啟用除錯模式以顯示詳細參數資訊(預設:false) |
|
||||
|
||||
**認證要求**:您必須提供以下其中一種:
|
||||
|
||||
- `user` + `token`(API 令牌認證),或
|
||||
- `remote-token`(遠端觸發令牌認證)
|
||||
|
||||
**參數格式**:`parameters` 欄位接受多行字串,每行包含一個 `key=value` 配對:
|
||||
|
||||
- 每個參數應該在單獨一行
|
||||
- 格式:`KEY=VALUE`(每行一個)
|
||||
- 空行會自動忽略
|
||||
- 只有空白的行會被跳過
|
||||
- 鍵名會去除前後空白
|
||||
- 值會保留有意義的空格
|
||||
- 值可以包含 `=` 符號(第一個 `=` 之後的所有內容都視為值)
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 命令列
|
||||
|
||||
**單一任務:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin
|
||||
```
|
||||
|
||||
**多個任務:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job drone-jenkins-plugin-1 \
|
||||
--job drone-jenkins-plugin-2
|
||||
```
|
||||
|
||||
**帶建置參數:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--parameters $'ENVIRONMENT=production\nVERSION=1.0.0'
|
||||
```
|
||||
|
||||
或使用環境變數:
|
||||
|
||||
```bash
|
||||
export JENKINS_PARAMETERS="ENVIRONMENT=production
|
||||
VERSION=1.0.0
|
||||
BRANCH=main"
|
||||
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**使用遠端令牌認證:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--remote-token REMOTE_TOKEN_HERE \
|
||||
--job my-jenkins-job
|
||||
```
|
||||
|
||||
**等待任務完成:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--wait \
|
||||
--poll-interval 15s \
|
||||
--timeout 1h
|
||||
```
|
||||
|
||||
**使用除錯模式:**
|
||||
|
||||
```bash
|
||||
drone-jenkins \
|
||||
--host http://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--debug
|
||||
```
|
||||
|
||||
**使用自訂 CA 憑證:**
|
||||
|
||||
```bash
|
||||
# 使用檔案路徑
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert /path/to/ca.pem
|
||||
|
||||
# 使用 URL
|
||||
drone-jenkins \
|
||||
--host https://jenkins.example.com/ \
|
||||
--user appleboy \
|
||||
--token XXXXXXXX \
|
||||
--job my-jenkins-job \
|
||||
--ca-cert https://example.com/ca-bundle.crt
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
**單一任務:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**多個任務:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=drone-jenkins-plugin-1,drone-jenkins-plugin-2 \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**帶建置參數:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_PARAMETERS=$'ENVIRONMENT=production\nVERSION=1.0.0\nBRANCH=main' \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**等待任務完成:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_WAIT=true \
|
||||
-e JENKINS_POLL_INTERVAL=15s \
|
||||
-e JENKINS_TIMEOUT=1h \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**使用除錯模式:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=http://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_DEBUG=true \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
**使用自訂 CA 憑證:**
|
||||
|
||||
```bash
|
||||
# 使用掛載的憑證檔案
|
||||
docker run --rm \
|
||||
-v /path/to/ca.pem:/ca.pem:ro \
|
||||
-e JENKINS_URL=https://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_CA_CERT=/ca.pem \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
|
||||
# 使用 URL
|
||||
docker run --rm \
|
||||
-e JENKINS_URL=https://jenkins.example.com/ \
|
||||
-e JENKINS_USER=appleboy \
|
||||
-e JENKINS_TOKEN=xxxxxxx \
|
||||
-e JENKINS_JOB=my-jenkins-job \
|
||||
-e JENKINS_CA_CERT=https://example.com/ca-bundle.crt \
|
||||
ghcr.io/appleboy/drone-jenkins
|
||||
```
|
||||
|
||||
更多詳細範例和進階設定,請參閱 [DOCS.md](DOCS.md)。
|
||||
|
||||
## 開發
|
||||
|
||||
### 建置
|
||||
|
||||
建置執行檔:
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
建置 Docker 映像檔:
|
||||
|
||||
```sh
|
||||
make docker
|
||||
```
|
||||
|
||||
### 測試
|
||||
|
||||
執行測試套件:
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
執行測試並產生覆蓋率報告:
|
||||
|
||||
```sh
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
## 授權條款
|
||||
|
||||
Copyright (c) 2019 Bo-Yi Wu
|
||||
|
||||
## 貢獻
|
||||
|
||||
歡迎貢獻!請隨時提交 Pull Request。
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="Bo-Yi Wu <appleboy.tw@gmail.com>" \
|
||||
org.label-schema.name="Drone Jenkins Plugin" \
|
||||
org.label-schema.vendor="Bo-Yi Wu" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/appleboy/drone-jenkins
|
||||
LABEL org.opencontainers.image.description="Drone Jenkins"
|
||||
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"]
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
hash: 0ddd797a78c8eef869d73907846b08ce2ba1dc91ddb0c644b80e28a949f79819
|
||||
updated: 2016-10-18T10:10:10.836829677+08:00
|
||||
imports:
|
||||
- name: github.com/joho/godotenv
|
||||
version: 4ed13390c0acd2ff4e371e64d8b97c8954138243
|
||||
subpackages:
|
||||
- autoload
|
||||
- name: github.com/urfave/cli
|
||||
version: 55f715e28c46073d0e217e2ce8eb46b0b45e3db6
|
||||
testImports:
|
||||
- name: github.com/davecgh/go-spew
|
||||
version: 6d212800a42e8ab5c146b8ace3490ee17e5225f9
|
||||
subpackages:
|
||||
- spew
|
||||
- name: github.com/pmezard/go-difflib
|
||||
version: d8ed2627bdf02c080bf22230dbb337003b7aba2d
|
||||
subpackages:
|
||||
- difflib
|
||||
- name: github.com/stretchr/testify
|
||||
version: 976c720a22c8eb4eb6a0b4348ad85ad12491a506
|
||||
subpackages:
|
||||
- assert
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package: github.com/appleboy/drone-jenkins
|
||||
import:
|
||||
- package: github.com/joho/godotenv
|
||||
subpackages:
|
||||
- autoload
|
||||
- package: github.com/urfave/cli
|
||||
testImport:
|
||||
- package: github.com/stretchr/testify
|
||||
subpackages:
|
||||
- assert
|
||||
@@ -0,0 +1,20 @@
|
||||
module github.com/appleboy/drone-jenkins
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/appleboy/com v1.1.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/yassinebenaid/godump v0.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
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=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
+283
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
+468
-27
@@ -1,12 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"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)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
// 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
|
||||
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 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)
|
||||
}
|
||||
|
||||
+723
-8
@@ -1,10 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseJobPath(t *testing.T) {
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,122 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/urfave/cli"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/yassinebenaid/godump"
|
||||
)
|
||||
|
||||
// Version for command line
|
||||
var Version string
|
||||
// Version set at compile-time
|
||||
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 {
|
||||
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 {
|
||||
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 = "jenkins plugin"
|
||||
app.Usage = "trigger jenkins jobs"
|
||||
app.Copyright = "Copyright (c) 2019 Bo-Yi Wu"
|
||||
app.Authors = []*cli.Author{
|
||||
{
|
||||
Name: "Bo-Yi Wu",
|
||||
Email: "appleboy.tw@gmail.com",
|
||||
},
|
||||
}
|
||||
app.Action = run
|
||||
app.Version = Version
|
||||
app.Flags = []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "base.url",
|
||||
Usage: "jenkins base url",
|
||||
EnvVar: "PLUGIN_BASE_URL,JENKINS_BASE_URL",
|
||||
&cli.StringFlag{
|
||||
Name: "host",
|
||||
Usage: "jenkins base url",
|
||||
EnvVars: []string{"PLUGIN_URL", "JENKINS_URL", "INPUT_URL"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "username",
|
||||
Usage: "jenkins username",
|
||||
EnvVar: "PLUGIN_USERNAME,JENKINS_USERNAME",
|
||||
&cli.StringFlag{
|
||||
Name: "user",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "jenkins username",
|
||||
EnvVars: []string{"PLUGIN_USER", "JENKINS_USER", "INPUT_USER"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "token",
|
||||
Usage: "jenkins token",
|
||||
EnvVar: "PLUGIN_TOKEN,JENKINS_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",
|
||||
Usage: "jenkins job",
|
||||
EnvVar: "PLUGIN_JOB",
|
||||
&cli.StringFlag{
|
||||
Name: "remote-token",
|
||||
Usage: "jenkins remote trigger token",
|
||||
EnvVars: []string{"PLUGIN_REMOTE_TOKEN", "JENKINS_REMOTE_TOKEN", "INPUT_REMOTE_TOKEN"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "repo.owner",
|
||||
Usage: "repository owner",
|
||||
EnvVar: "DRONE_REPO_OWNER",
|
||||
&cli.StringSliceFlag{
|
||||
Name: "job",
|
||||
Aliases: []string{"j"},
|
||||
Usage: "jenkins job",
|
||||
EnvVars: []string{"PLUGIN_JOB", "JENKINS_JOB", "INPUT_JOB"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "repo.name",
|
||||
Usage: "repository name",
|
||||
EnvVar: "DRONE_REPO_NAME",
|
||||
&cli.BoolFlag{
|
||||
Name: "insecure",
|
||||
Usage: "allow insecure server connections when using SSL",
|
||||
EnvVars: []string{"PLUGIN_INSECURE", "JENKINS_INSECURE", "INPUT_INSECURE"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "commit.sha",
|
||||
Usage: "git commit sha",
|
||||
EnvVar: "DRONE_COMMIT_SHA",
|
||||
&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: "commit.branch",
|
||||
Value: "master",
|
||||
Usage: "git commit branch",
|
||||
EnvVar: "DRONE_COMMIT_BRANCH",
|
||||
&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.StringFlag{
|
||||
Name: "commit.author",
|
||||
Usage: "git author name",
|
||||
EnvVar: "DRONE_COMMIT_AUTHOR",
|
||||
&cli.BoolFlag{
|
||||
Name: "wait",
|
||||
Usage: "wait for job completion",
|
||||
EnvVars: []string{"PLUGIN_WAIT", "JENKINS_WAIT", "INPUT_WAIT"},
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "commit.message",
|
||||
Usage: "commit message",
|
||||
EnvVar: "DRONE_COMMIT_MESSAGE",
|
||||
&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.StringFlag{
|
||||
Name: "build.event",
|
||||
Value: "push",
|
||||
Usage: "build event",
|
||||
EnvVar: "DRONE_BUILD_EVENT",
|
||||
&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.IntFlag{
|
||||
Name: "build.number",
|
||||
Usage: "build number",
|
||||
EnvVar: "DRONE_BUILD_NUMBER",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "build.status",
|
||||
Usage: "build status",
|
||||
Value: "success",
|
||||
EnvVar: "DRONE_BUILD_STATUS",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "build.link",
|
||||
Usage: "build link",
|
||||
EnvVar: "DRONE_BUILD_LINK",
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "enable debug mode to show detailed parameter information",
|
||||
EnvVars: []string{"PLUGIN_DEBUG", "JENKINS_DEBUG", "INPUT_DEBUG"},
|
||||
},
|
||||
}
|
||||
app.Run(os.Args)
|
||||
|
||||
// Override a template
|
||||
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}}
|
||||
{{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}}
|
||||
GLOBAL OPTIONS:
|
||||
{{range .VisibleFlags}}{{.}}
|
||||
{{end}}{{end}}{{if .Copyright }}
|
||||
COPYRIGHT:
|
||||
{{.Copyright}}
|
||||
{{end}}{{if .Version}}
|
||||
VERSION:
|
||||
{{.Version}}
|
||||
{{end}}
|
||||
REPOSITORY:
|
||||
Github: https://github.com/appleboy/drone-jenkins
|
||||
`
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error {
|
||||
plugin := Plugin{
|
||||
Repo: Repo{
|
||||
Owner: c.String("repo.owner"),
|
||||
Name: c.String("repo.name"),
|
||||
},
|
||||
Build: Build{
|
||||
Number: c.Int("build.number"),
|
||||
Event: c.String("build.event"),
|
||||
Status: c.String("build.status"),
|
||||
Commit: c.String("commit.sha"),
|
||||
Branch: c.String("commit.branch"),
|
||||
Author: c.String("commit.author"),
|
||||
Message: c.String("commit.message"),
|
||||
Link: c.String("build.link"),
|
||||
},
|
||||
Config: Config{
|
||||
BaseURL: c.String("base.url"),
|
||||
Username: c.String("username"),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,77 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
// Repo information.
|
||||
Repo struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Build information.
|
||||
Build struct {
|
||||
Event string
|
||||
Number int
|
||||
Commit string
|
||||
Message string
|
||||
Branch string
|
||||
Author string
|
||||
Status string
|
||||
Link string
|
||||
}
|
||||
|
||||
// Config for the plugin.
|
||||
Config struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Token string
|
||||
Job []string
|
||||
}
|
||||
|
||||
// Plugin values.
|
||||
// Plugin represents the configuration for the Jenkins plugin.
|
||||
// It contains all necessary credentials and settings to trigger Jenkins jobs.
|
||||
Plugin struct {
|
||||
Repo Repo
|
||||
Build Build
|
||||
Config Config
|
||||
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 {
|
||||
var 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 {
|
||||
// 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{}
|
||||
|
||||
if len(p.Config.BaseURL) == 0 || len(p.Config.Username) == 0 || len(p.Config.Token) == 0 {
|
||||
log.Println("missing jenkins config")
|
||||
// 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
|
||||
}
|
||||
|
||||
return errors.New("missing jenkins config")
|
||||
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)
|
||||
}
|
||||
|
||||
auth := &Auth{
|
||||
Username: p.Config.Username,
|
||||
Token: p.Config.Token,
|
||||
}
|
||||
jenkins := NewJenkins(auth, p.Config.BaseURL)
|
||||
return values
|
||||
}
|
||||
|
||||
for _, value := range trimElement(p.Config.Job) {
|
||||
jenkins.trigger(value, nil)
|
||||
// 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("at least one Jenkins job name is required")
|
||||
}
|
||||
|
||||
// Set up authentication (only if username and token are provided)
|
||||
var auth *Auth
|
||||
if p.Username != "" && p.Token != "" {
|
||||
auth = &Auth{
|
||||
Username: p.Username,
|
||||
Token: p.Token,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Jenkins client
|
||||
jenkins, err := NewJenkins(ctx, auth, p.BaseURL, p.RemoteToken, p.Insecure, p.CACert, p.Debug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize Jenkins client: %w", err)
|
||||
}
|
||||
|
||||
// Parse job parameters
|
||||
params := parseParameters(p.Parameters)
|
||||
|
||||
// Set default values for wait configuration
|
||||
pollInterval := p.PollInterval
|
||||
if pollInterval == 0 {
|
||||
pollInterval = 10 * time.Second
|
||||
}
|
||||
|
||||
timeout := p.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Minute
|
||||
}
|
||||
|
||||
// Trigger each job
|
||||
for _, jobName := range jobs {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+486
-42
@@ -1,70 +1,514 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMissingConfig(t *testing.T) {
|
||||
const (
|
||||
testJobBuildPath = "/job/test-job/build"
|
||||
testQueueItemPath = "/queue/item/123/api/json"
|
||||
testBuildStatusPath = "/job/test-job/456/api/json"
|
||||
)
|
||||
|
||||
// TestValidateConfig tests the validateConfig method
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
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(), "jenkins base URL is required")
|
||||
}
|
||||
|
||||
func TestMissingJenkinsConfig(t *testing.T) {
|
||||
// TestExecMissingJenkinsUsername tests Exec with missing username
|
||||
func TestExecMissingJenkinsUsername(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
Config: Config{
|
||||
BaseURL: "http://example.com",
|
||||
BaseURL: "http://example.com",
|
||||
}
|
||||
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "configuration error")
|
||||
assert.Contains(t, err.Error(), "authentication required")
|
||||
}
|
||||
|
||||
// TestExecMissingJenkinsToken tests Exec with missing token
|
||||
func TestExecMissingJenkinsToken(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
}
|
||||
|
||||
err := plugin.Exec(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "configuration error")
|
||||
assert.Contains(t, err.Error(), "authentication required")
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
|
||||
err := plugin.Exec()
|
||||
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,
|
||||
}
|
||||
|
||||
assert.NotNil(t, err)
|
||||
err := plugin.Exec(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one Jenkins job name is required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginTriggerBuild(t *testing.T) {
|
||||
plugin := Plugin{
|
||||
Repo: Repo{
|
||||
Name: "go-hello",
|
||||
Owner: "appleboy",
|
||||
},
|
||||
Build: Build{
|
||||
Number: 101,
|
||||
Status: "success",
|
||||
Link: "https://github.com/appleboy/go-hello",
|
||||
Author: "Bo-Yi Wu",
|
||||
Branch: "master",
|
||||
Message: "update by drone line plugin.",
|
||||
Commit: "e7c4f0a63ceeb42a39ac7806f7b51f3f0d204fd2",
|
||||
},
|
||||
// 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()
|
||||
|
||||
Config: Config{
|
||||
BaseURL: "http://example.com",
|
||||
Username: "foo",
|
||||
Token: "bar",
|
||||
Job: []string{"drone-jenkins"},
|
||||
},
|
||||
plugin := Plugin{
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user