diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0629f7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23.1' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Run tests + run: go test -v ./... + + - name: Run go vet + run: go vet ./... + + - name: Run go fmt check + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Code is not formatted properly" + gofmt -s -l . + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1e747d4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,229 @@ +# .github/workflows/release.yml - 自动发布 (基于VERSION文件) +name: Auto Release + +on: + push: + branches: [ main ] # 当PR合并到main时触发 + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # 获取完整历史,用于生成changelog + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23.1' + + - name: Check VERSION file and determine release + id: version + run: | + # 检查VERSION文件是否存在 + if [ ! -f "VERSION" ]; then + echo "❌ VERSION file not found!" + echo "Please create a VERSION file in the root directory with format: 1.0.0" + exit 1 + fi + + # 读取VERSION文件内容 + CURRENT_VERSION=$(cat VERSION | tr -d '[:space:]') + echo "Version in file: $CURRENT_VERSION" + + # 验证版本格式 (x.y.z) + if ! echo "$CURRENT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+ + + - name: Build binaries + if: steps.version.outputs.skip_release != 'true' + run: | + VERSION=${{ steps.version.outputs.current_version }} + mkdir -p dist + + # 构建不同平台的二进制文件 + platforms=("linux/amd64" "linux/arm64" "windows/amd64" "darwin/amd64" "darwin/arm64") + + for platform in "${platforms[@]}"; do + platform_split=(${platform//\// }) + GOOS=${platform_split[0]} + GOARCH=${platform_split[1]} + + output_name="bamboo-exporter-v${VERSION}-${GOOS}-${GOARCH}" + if [ $GOOS = "windows" ]; then + output_name+='.exe' + fi + + echo "Building for $GOOS/$GOARCH..." + env CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build \ + -ldflags="-s -w -X main.version=v${VERSION}" \ + -o dist/$output_name . + + # 创建压缩包 + cd dist + if [ $GOOS = "windows" ]; then + zip ${output_name%.exe}.zip $output_name + rm $output_name + else + tar -czf ${output_name}.tar.gz $output_name + rm $output_name + fi + cd .. + done + + - name: Create Release + if: steps.version.outputs.skip_release != 'true' + uses: actions/create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.version.outputs.new_version }} + release_name: Release ${{ steps.version.outputs.new_version }} + body: | + ## 🚀 What's New + + ${{ steps.version.outputs.changelog }} + + ## 📦 Download + + 选择适合你平台的二进制文件下载: + - **Linux (x64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-linux-amd64.tar.gz` + - **Linux (ARM64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-linux-arm64.tar.gz` + - **Windows (x64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-windows-amd64.zip` + - **macOS (Intel)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-darwin-amd64.tar.gz` + - **macOS (Apple Silicon)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-darwin-arm64.tar.gz` + draft: false + prerelease: false + + - name: Upload Release Assets + if: steps.version.outputs.skip_release != 'true' + run: | + upload_url="${{ steps.create_release.outputs.upload_url }}" + + for file in dist/*; do + filename=$(basename "$file") + echo "Uploading $filename..." + curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$file" \ + "${upload_url%{?name,label}*}?name=$filename" + done; then + echo "❌ Invalid version format in VERSION file: $CURRENT_VERSION" + echo "Expected format: x.y.z (e.g., 1.0.0)" + exit 1 + fi + + # 添加v前缀 + NEW_VERSION="v${CURRENT_VERSION}" + + # 获取最后一个tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Last release tag: $LAST_TAG" + echo "New version: $NEW_VERSION" + + # 检查版本是否有变化 + if [ "$NEW_VERSION" = "$LAST_TAG" ]; then + echo "✅ Version $NEW_VERSION already released, skipping..." + echo "skip_release=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # 检查版本是否比上一个版本新 + if [ "$LAST_TAG" != "v0.0.0" ]; then + LAST_VERSION=$(echo $LAST_TAG | sed 's/v//') + if ! printf '%s\n%s\n' "$LAST_VERSION" "$CURRENT_VERSION" | sort -V -C; then + echo "⚠️ Warning: New version $CURRENT_VERSION is not higher than last version $LAST_VERSION" + echo "This might indicate a version rollback or incorrect versioning" + fi + fi + + echo "✅ Version changed from $LAST_TAG to $NEW_VERSION, creating release..." + echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT + echo "current_version=${CURRENT_VERSION}" >> $GITHUB_OUTPUT + + # 生成changelog (从上个tag到现在的提交) + if [ "$LAST_TAG" = "v0.0.0" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" --reverse HEAD) + else + CHANGELOG=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --reverse) + fi + + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Build binaries + if: steps.version.outputs.skip_release != 'true' + run: | + VERSION=${{ steps.version.outputs.new_version }} + mkdir -p dist + + # 构建不同平台的二进制文件 + platforms=("linux/amd64" "linux/arm64" "windows/amd64" "darwin/amd64" "darwin/arm64") + + for platform in "${platforms[@]}"; do + platform_split=(${platform//\// }) + GOOS=${platform_split[0]} + GOARCH=${platform_split[1]} + + output_name="bamboo-exporter-${VERSION}-${GOOS}-${GOARCH}" + if [ $GOOS = "windows" ]; then + output_name+='.exe' + fi + + echo "Building for $GOOS/$GOARCH..." + env CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/$output_name . + + # 创建压缩包 + cd dist + if [ $GOOS = "windows" ]; then + zip ${output_name%.exe}.zip $output_name + rm $output_name + else + tar -czf ${output_name}.tar.gz $output_name + rm $output_name + fi + cd .. + done + + - name: Create Release + if: steps.version.outputs.skip_release != 'true' + uses: actions/create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.version.outputs.new_version }} + release_name: Release ${{ steps.version.outputs.new_version }} + body: | + ## 🚀 What's New + + ${{ steps.version.outputs.changelog }} + + ## 📦 Download + + 选择适合你平台的二进制文件下载: + - **Linux (x64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-linux-amd64.tar.gz` + - **Linux (ARM64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-linux-arm64.tar.gz` + - **Windows (x64)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-windows-amd64.zip` + - **macOS (Intel)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-darwin-amd64.tar.gz` + - **macOS (Apple Silicon)**: `bamboo-exporter-${{ steps.version.outputs.new_version }}-darwin-arm64.tar.gz` + draft: false + prerelease: false + + - name: Upload Release Assets + if: steps.version.outputs.skip_release != 'true' + run: | + upload_url="${{ steps.create_release.outputs.upload_url }}" + + for file in dist/*; do + filename=$(basename "$file") + echo "Uploading $filename..." + curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$file" \ + "${upload_url%{?name,label}*}?name=$filename" + done \ No newline at end of file diff --git a/.ignore b/.gitignore similarity index 100% rename from .ignore rename to .gitignore diff --git a/README.md b/README.md index dd6555d..54a4331 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ # bamboo-prometheus-exporter + Bamboo-prometheus-exporter is a tool that scrapes metrics from Atlassian Bamboo and exposes them for Prometheus monitoring, enabling detailed CI/CD pipeline observability. + +## Features +- **Agent Monitoring** + Track agent status (enabled/active/busy) with labels including ID, name, and type +- **Build Queue Analytics** + Monitor queue size changes and agent utilization rates in real-time +- **Project Build Metrics** + Capture success/failure counts. Capture total builds per project-plan combination +- **Dynamic Labeling** + Auto-split plan.name into project and name labels (e.g., XXXX Releases - PB-XXXX-21.2 → project="XXXX Releases", name="PB-XXXX-21.2") + +## Installation + +### Binary +```bash +go install github.com/EIETS/bamboo_exporter@latest +./bamboo_exporter \ + --bamboo.uri="http://bamboo.example.com" \ + --web.listen-address=":9117" +``` + +### Docker +```bash +docker run -d -p 9117:9117 -v ./config.json:/app/config.json eitets/bamboo_exporter \ + --bamboo.uri="https://bamboo.example.com" \ + --insecure +``` + +## Configuration +### Flags +| Parameter | Default | Description | +|:-----------------------|:------------------------|:-------------------------| +| --telemetry.endpoint | /metrics | Metrics exposure path | +| --bamboo.uri | http://localhost:8085 | Bamboo server URL | +| --insecure | false | Disable SSL verification | +| --web.listen-address | :9117 | Exporter listening port | + +### Authentication +Create config.json: +```json +{ + "bamboo_username": "admin", + "bamboo_password": "secure_password" +} +``` + +### Exported Metrics +| Metric Name | Type | Labels | Description | +|:-----------------------------|:--------|:--------------------------------------|:--------------------------------------| +| bamboo_agents_status | Gauge | id, name, type, enabled, active, busy | Agent operational states | +| bamboo_queue_size | Gauge | - | Current build queue count | +| bamboo_queue_change | Gauge | - | Queue size delta since last scrape | +| bamboo_agent_utilization | Gauge | - | Busy/active agent ratio | +| bamboo_build_success_total | Counter | - | Total successful builds | +| bamboo_build_failure_total | Counter | - | Total failed builds | +| bamboo_build_total | Gauge | project, name | Latest build number per plan | + +## FAQ +Q: Metrics not appearing? + +A.Verify Bamboo API accessibility: curl -u user:pass http://bamboo-server/rest/api/latest/queue + +Check label parsing rules in parseProjectAndName() + +Q: Authentication failures? + +A.Ensure config.json has 600 permissions + +Confirm user has ​​View Configuration​​ permissions in Bamboo + +Q: Handle large-scale deployments? + +A.Deploy multiple exporters with Prometheus service discovery + + diff --git a/bamboo_exporter.go b/bamboo_exporter.go index 3cb3e06..5011720 100644 --- a/bamboo_exporter.go +++ b/bamboo_exporter.go @@ -65,6 +65,7 @@ func main() { os.Exit(0) }() + // expose metrics endpoint http.Handle(*metricsEndpoint, promhttp.Handler()) // configure the landing page diff --git a/collector/collector.go b/collector/collector.go index 2143c2c..51f38a0 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -7,8 +7,10 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strconv" + "strings" "sync" "log/slog" @@ -31,6 +33,9 @@ type Exporter struct { queueChange prometheus.Gauge logger *slog.Logger previousQueueSize int64 + buildSuccess prometheus.Counter + buildFailure prometheus.Counter + buildCount *prometheus.GaugeVec } // Config holds the configuration for the exporter. @@ -56,6 +61,20 @@ type BambooQueue struct { } `json:"queuedBuilds"` } +// response represents the response from the Bamboo API /rest/api/latest/result. +type response struct { + Results struct { + Size int `json:"size"` + Result []struct { + Plan struct { + Name string `json:"name"` + } `json:"plan"` + BuildNumber int `json:"buildNumber"` + State string `json:"state"` + } `json:"result"` + } `json:"results"` +} + // NewExporter creates a new instance of Exporter. func NewExporter(config *Config, logger *slog.Logger) *Exporter { return &Exporter{ @@ -96,6 +115,21 @@ func NewExporter(config *Config, logger *slog.Logger) *Exporter { Name: "queue_change", Help: "Change in the Bamboo build queue size since the last scrape.", }), + buildSuccess: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "build_success_total", + Help: "Total successful builds", + }), + buildFailure: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: namespace, + Name: "build_failure_total", + Help: "Total failed builds ", + }), + buildCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "build_total", + Help: "Total builds executed per project version", + }, []string{"project", "name"}), logger: logger, } } @@ -108,6 +142,9 @@ func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { e.queue.Describe(ch) e.utilization.Describe(ch) e.queueChange.Describe(ch) + e.buildSuccess.Describe(ch) + e.buildFailure.Describe(ch) + e.buildCount.Describe(ch) } // Collect collects metrics from Bamboo and sends them to Prometheus. @@ -122,6 +159,9 @@ func (e *Exporter) Collect(ch chan<- prometheus.Metric) { e.queue.Collect(ch) e.utilization.Collect(ch) e.queueChange.Collect(ch) + e.buildSuccess.Collect(ch) + e.buildFailure.Collect(ch) + e.buildCount.Collect(ch) } // scrapeMetrics fetches metrics from Bamboo and processes them. @@ -138,6 +178,12 @@ func (e *Exporter) scrapeMetrics() float64 { return 0 } + if err := e.scrapeBuildResults(); err != nil { + e.logger.Error("Failed to scrape build results", "error", err) + e.failures.Inc() + return 0 + } + return 1 } @@ -201,6 +247,62 @@ func (e *Exporter) scrapeQueue() error { return nil } +// scrapeBuildResults fetches and processes build results from Bamboo. +func (e *Exporter) scrapeBuildResults() error { + currentIndex := 0 + totalSize := 0 + maxResult := 100 + + for { + params := url.Values{ + "start-index": []string{strconv.Itoa(currentIndex)}, + "max-result": []string{strconv.Itoa(maxResult)}, + "expand": []string{"results.result"}, + } + + response := response{} + + data, err := e.doRequest("/rest/api/latest/result?" + params.Encode()) + if err != nil { + return fmt.Errorf("error fetching result: %w", err) + } + + if err := json.Unmarshal(data, &response); err != nil { + return fmt.Errorf("error unmarshaling results: %w", err) + } + + if totalSize == 0 { + totalSize = response.Results.Size + } + + // process data of current page + for _, r := range response.Results.Result { + project, name := parseProjectAndName(r.Plan.Name) + labels := []string{project, name} + + switch r.State { + case "Successful": + e.buildSuccess.Inc() + default: + e.buildFailure.Inc() + } + + // set build count + e.buildCount.WithLabelValues(labels...).Set(float64(r.BuildNumber)) + + } + + // end of page + fetchedCount := currentIndex + len(response.Results.Result) + if fetchedCount >= totalSize || len(response.Results.Result) == 0 { + break + } + currentIndex = fetchedCount + } + + return nil +} + // doRequest sends a GET request to the Bamboo API and returns the response body. func (e *Exporter) doRequest(endpoint string) ([]byte, error) { configData, err := os.ReadFile("config.json") @@ -236,3 +338,15 @@ func (e *Exporter) doRequest(endpoint string) ([]byte, error) { return io.ReadAll(resp.Body) } + +func parseProjectAndName(planName string) (project, name string) { + parts := strings.SplitN(planName, " - ", 2) + if len(parts) >= 2 { + project = strings.TrimSpace(parts[0]) + name = strings.TrimSpace(parts[1]) + } else { + project = "Unknown" + name = strings.TrimSpace(planName) + } + return +}