From 49c242020a71e2de2f7aed7a3bfaa8cf4404cad1 Mon Sep 17 00:00:00 2001 From: Qichao Chu Date: Tue, 26 May 2026 13:47:15 -0700 Subject: [PATCH] build: add axion-release, Spotless, GHCR publish workflow --- .git-blame-ignore-revs | 6 + .github/workflows/release.yml | 70 ++++++++++ CONTRIBUTING.md | 21 ++- README.md | 56 +++++++- build.gradle | 235 ++++++++++++++++------------------ gradle/license-header.txt | 15 +++ 6 files changed, 274 insertions(+), 129 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 .github/workflows/release.yml create mode 100644 gradle/license-header.txt diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..d4bcc19 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Commits listed here are skipped by `git blame`. +# Locally, run: git config blame.ignoreRevsFile .git-blame-ignore-revs +# GitHub honors this file automatically. + +# Add the SHA of the one-shot Spotless reformat commit here once it lands: +# style: apply Google Java Format via Spotless diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..46eb6ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + packages: write + +jobs: + release: + name: Build and publish Docker image + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build (Spotless + Spotbugs + tests) + run: ./gradlew build -x integrationTest + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/ugroup + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: build/libs/*-boot.jar diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdd9091..1884673 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,7 @@ Thank you for your interest in contributing to uGroup! This document explains ho - **Tests required**: All new functionality must include automated tests. Bug fixes should include a test that reproduces the issue. - **Build must pass**: Run `./gradlew build` before submitting. This runs unit tests, integration tests, static analysis, and coverage checks. +- **Formatting**: Run `./gradlew spotlessApply` before submitting. CI runs `spotlessCheck` as part of `build` and will fail on unformatted code. - **Code coverage**: The project enforces a minimum coverage threshold via JaCoCo. New code should maintain or improve coverage. - **No broken windows**: Fix any static analysis warnings introduced by your changes. @@ -33,6 +34,15 @@ Thank you for your interest in contributing to uGroup! This document explains ho # Run integration tests (requires Docker) ./gradlew integrationTest + +# Apply Google Java Format +./gradlew spotlessApply +``` + +The repo includes a `.git-blame-ignore-revs` file that hides the one-shot Spotless reformat commit from `git blame`. GitHub honors it automatically; for local `git blame`, run once: + +```bash +git config blame.ignoreRevsFile .git-blame-ignore-revs ``` ## Reporting Issues @@ -46,14 +56,15 @@ Use [GitHub Issues](https://github.com/uber/uGroup/issues) to report bugs or req All pull requests require approval from at least one of the following code reviewers before merging: -| Reviewer | Email | Coverage Area | -|----------|-------|---------------| -| Qichao Chu | qichao@uber.com | All areas | -| Si Lao | sil@uber.com | All areas | +| Reviewer | GitHub | Coverage Area | +|----------|--------|---------------| +| Qichao Chu | [@ex172000](https://github.com/ex172000) | All areas | +| Si Lao | [@laosiaudi](https://github.com/laosiaudi) | All areas | +| Yulan Feng | [@yfeng21](https://github.com/yfeng21) | All areas | **Review process:** - Every PR must be reviewed and approved by at least one of the engineers listed above. -- For critical changes (security fixes, breaking changes, or core processing logic), approval from both reviewers is recommended. +- For critical changes (security fixes, breaking changes, or core processing logic), approval from two reviewers is recommended. - Reviewers should be assigned via GitHub's reviewer request feature when opening a PR. - Reviews should focus on correctness, test coverage, performance implications, and adherence to project conventions. diff --git a/README.md b/README.md index 0e36df6..898bd8c 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ uGroup monitors Kafka consumer groups by reading the `__consumer_offsets` topic ```bash cd docker -docker-compose up -d +docker compose up -d ``` -This starts Kafka, Zookeeper, and uGroup. Access the API at `http://localhost:8080`. +This starts Kafka, Zookeeper, and uGroup. Point uGroup at any consumer group running against the same broker and lag will appear at `http://localhost:8080`. ### Using Gradle @@ -33,6 +33,58 @@ This starts Kafka, Zookeeper, and uGroup. Access the API at `http://localhost:80 KAFKA_BOOTSTRAP_SERVERS=localhost:9092 ./gradlew bootRun ``` +## Usage + +### Query lag via REST + +```bash +# Lag for every topic a group consumes +curl http://localhost:8080/api/v1/lag/my-consumer-group + +# Lag for one group/topic pair +curl http://localhost:8080/api/v1/lag/my-consumer-group/events-topic + +# Liveness / status +curl http://localhost:8080/api/v1/status +``` + +### Scrape lag with Prometheus + +uGroup exposes metrics at `/actuator/prometheus`. Example PromQL for the worst-lagging group in each cluster: + +```promql +max by (cluster, group) (ugroup_consumer_lag) +``` + +See the [Metrics wiki page](https://github.com/uber/uGroup/wiki/Metrics) for the full metric and label reference. + +## Common scenarios + +uGroup decides which groups to monitor via `UGROUP_WATCHLIST_MODE`. Pick the mode that fits your use case and see the linked wiki page for the YAML/regex details. + +**Monitor every consumer group in the cluster** — zero config, useful for ops dashboards: + +```bash +UGROUP_WATCHLIST_MODE=all ./gradlew bootRun +``` + +**Monitor a curated list** — declare the exact groups + topics you care about in a YAML file (see [`watchlist-sample.yaml`](src/main/resources/watchlist-sample.yaml) and the [Watchlist wiki page](https://github.com/uber/uGroup/wiki/Watchlist-Configuration)): + +```bash +UGROUP_WATCHLIST_MODE=static UGROUP_WATCHLIST_FILE=/etc/ugroup/watchlist.yaml ./gradlew bootRun +``` + +**Monitor by regex with a blocklist** — useful when group names follow a naming convention but a few should be excluded (see the [Blocklist wiki page](https://github.com/uber/uGroup/wiki/Blocklist-Configuration)): + +```bash +UGROUP_WATCHLIST_MODE=regex \ + UGROUP_INCLUDE_PATTERNS='prod-.*' \ + UGROUP_BLOCKLIST_FILE=/etc/ugroup/blocklist.yaml \ + ./gradlew bootRun +``` + +For the full list of environment variables and their defaults, see the [Configuration wiki page](https://github.com/uber/uGroup/wiki/Configuration). + ## Documentation Full documentation is available on the [Wiki](https://github.com/uber/uGroup/wiki): diff --git a/build.gradle b/build.gradle index 6263121..6d3a034 100644 --- a/build.gradle +++ b/build.gradle @@ -1,185 +1,176 @@ plugins { - id 'java-library' - id 'org.springframework.boot' version '3.2.0' - id 'io.spring.dependency-management' version '1.1.7' - id 'maven-publish' - id 'jacoco' - id 'com.github.spotbugs' version '6.0.7' + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' + id 'com.github.spotbugs' version '6.0.7' + id 'com.diffplug.spotless' version '6.25.0' + id 'pl.allegro.tech.build.axion-release' version '1.18.16' } group = 'com.uber.ugroup' -version = '1.0.0-SNAPSHOT' + +scmVersion { + tag { + prefix = 'v' + versionSeparator = '' + } + versionIncrementer 'incrementPatch' + snapshotCreator { version, position -> '-SNAPSHOT' } +} +project.version = scmVersion.version java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - withJavadocJar() - withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +spotless { + java { + target 'src/*/java/**/*.java' + googleJavaFormat('1.22.0') + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + licenseHeaderFile rootProject.file('gradle/license-header.txt') + } + groovyGradle { + target '*.gradle' + greclipse() + } } tasks.withType(JavaCompile).configureEach { - options.compilerArgs += ['-Xlint:all', '-Xlint:-processing'] + options.compilerArgs += [ + '-Xlint:all', + '-Xlint:-processing' + ] } import com.github.spotbugs.snom.Effort import com.github.spotbugs.snom.Confidence spotbugs { - effort = Effort.valueOf('MAX') - reportLevel = Confidence.valueOf('HIGH') + effort = Effort.valueOf('MAX') + reportLevel = Confidence.valueOf('HIGH') } tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach { - reports { - html.required = true - xml.required = false - } + reports { + html.required = true + xml.required = false + } } repositories { - mavenCentral() + mavenCentral() } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } dependencies { - // Spring Boot - implementation 'org.springframework.boot:spring-boot-starter' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + // Spring Boot + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - // Kafka - implementation 'org.apache.kafka:kafka-clients:4.2.0' - // For parsing __consumer_offsets topic records - implementation 'org.apache.kafka:kafka-group-coordinator:4.2.0' + // Kafka + implementation 'org.apache.kafka:kafka-clients:4.2.0' + // For parsing __consumer_offsets topic records + implementation 'org.apache.kafka:kafka-group-coordinator:4.2.0' - // Metrics (default implementation) - implementation 'io.micrometer:micrometer-registry-prometheus' + // Metrics (default implementation) + implementation 'io.micrometer:micrometer-registry-prometheus' - // Caching (default implementation) - implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3' + // Caching (default implementation) + implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3' - // YAML parsing - implementation 'org.yaml:snakeyaml:2.6' + // YAML parsing + implementation 'org.yaml:snakeyaml:2.6' - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' - // Testing - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.junit.jupiter:junit-jupiter:6.1.0' - testImplementation 'org.mockito:mockito-core:5.23.0' - testImplementation 'org.mockito:mockito-junit-jupiter:5.23.0' - testImplementation 'org.assertj:assertj-core:3.27.7' + // Testing + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.junit.jupiter:junit-jupiter:6.1.0' + testImplementation 'org.mockito:mockito-core:5.23.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.23.0' + testImplementation 'org.assertj:assertj-core:3.27.7' - // Integration testing - testImplementation 'org.testcontainers:testcontainers:1.21.4' - testImplementation 'org.testcontainers:kafka:1.21.4' - testImplementation 'org.testcontainers:junit-jupiter:1.21.4' - testImplementation 'org.awaitility:awaitility:4.3.0' + // Integration testing + testImplementation 'org.testcontainers:testcontainers:1.21.4' + testImplementation 'org.testcontainers:kafka:1.21.4' + testImplementation 'org.testcontainers:junit-jupiter:1.21.4' + testImplementation 'org.awaitility:awaitility:4.3.0' } sourceSets { - integrationTest { - java { - srcDir 'src/integrationTest/java' - } - resources { - srcDir 'src/integrationTest/resources' - } - compileClasspath += sourceSets.main.output + sourceSets.test.output - runtimeClasspath += sourceSets.main.output + sourceSets.test.output - } + integrationTest { + java { + srcDir 'src/integrationTest/java' + } + resources { + srcDir 'src/integrationTest/resources' + } + compileClasspath += sourceSets.main.output + sourceSets.test.output + runtimeClasspath += sourceSets.main.output + sourceSets.test.output + } } configurations { - integrationTestImplementation.extendsFrom testImplementation - integrationTestRuntimeOnly.extendsFrom testRuntimeOnly + integrationTestImplementation.extendsFrom testImplementation + integrationTestRuntimeOnly.extendsFrom testRuntimeOnly } tasks.register('integrationTest', Test) { - description = 'Runs integration tests.' - group = 'verification' - testClassesDirs = sourceSets.integrationTest.output.classesDirs - classpath = sourceSets.integrationTest.runtimeClasspath - shouldRunAfter test + description = 'Runs integration tests.' + group = 'verification' + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + shouldRunAfter test - systemProperty 'kafka.test.image', System.getProperty('kafka.test.image', 'confluentinc/cp-kafka:7.5.0') + systemProperty 'kafka.test.image', System.getProperty('kafka.test.image', 'confluentinc/cp-kafka:7.5.0') - useJUnitPlatform() + useJUnitPlatform() } test { - useJUnitPlatform() - finalizedBy jacocoTestReport + useJUnitPlatform() + finalizedBy jacocoTestReport } jacocoTestReport { - dependsOn test - reports { - xml.required = true - html.required = true - csv.required = false - } + dependsOn test + reports { + xml.required = true + html.required = true + csv.required = false + } } jacocoTestCoverageVerification { - violationRules { - rule { - limit { - minimum = 0.8 - } - } - } + violationRules { + rule { + limit { + minimum = 0.8 + } + } + } } check.dependsOn integrationTest -publishing { - publications { - maven(MavenPublication) { - from components.java - - pom { - name = 'uGroup' - description = 'Kafka Consumer Group Monitor - Monitor consumer lag by reading __consumer_offsets topic' - url = 'https://github.com/uber/uGroup' - - licenses { - license { - name = 'Apache License, Version 2.0' - url = 'https://www.apache.org/licenses/LICENSE-2.0' - } - } - - developers { - developer { - id = 'uber' - name = 'Uber Technologies' - email = 'oss@uber.com' - } - } - - scm { - connection = 'scm:git:git://github.com/uber/uGroup.git' - developerConnection = 'scm:git:ssh://github.com/uber/uGroup.git' - url = 'https://github.com/uber/uGroup' - } - } - } - } -} - bootJar { - archiveClassifier = 'boot' + archiveClassifier = 'boot' } jar { - enabled = true + enabled = true } diff --git a/gradle/license-header.txt b/gradle/license-header.txt new file mode 100644 index 0000000..b41d7fb --- /dev/null +++ b/gradle/license-header.txt @@ -0,0 +1,15 @@ +/* + * Copyright $YEAR Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */