// SonarQube 정적 보안 분석 전용 파이프라인. // // 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다) // 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME) // 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (이름: SONAR_SCANNER_TOOL) // 3) Quality Gate 대기를 쓰려면 SonarQube 서버에 // Administration > Configuration > Webhooks 에 /sonarqube-webhook/ 등록 // 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다. // // 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다. // (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError) pipeline { agent { label 'djb-vm' } triggers { pollSCM('H/30 * * * *') } options { timestamps() disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '20')) } parameters { booleanParam( name: 'RUN_TESTS', defaultValue: false, description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' ) } environment { // --- Jenkins 설정 이름 (환경에 맞게 수정) --- SONAR_ENV_NAME = 'SonarQube' SONAR_SCANNER_TOOL = 'SonarScanner' // --- 툴체인 --- JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8) JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용 GRADLE_HOME = '/apps/opts/gradle-8.7' GRADLE_USER_HOME = '/apps/opts/gradle-home' PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}" GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new' } stages { stage('Checkout') { steps { checkout scm } } // eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다. // 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다. stage('Checkout dependencies') { steps { sh ''' set -eu cd "$WORKSPACE/.." if [ ! -d elink-portal-common/.git ]; then rm -rf elink-portal-common git clone --depth=1 --branch master \ ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \ elink-portal-common else git -C elink-portal-common fetch --depth=1 origin master git -C elink-portal-common reset --hard origin/master git -C elink-portal-common clean -fdx fi mkdir -p eapim-online if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then rm -rf eapim-online/elink-online-core-jpa git clone --depth=1 --branch master \ ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \ eapim-online/elink-online-core-jpa else git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master git -C eapim-online/elink-online-core-jpa reset --hard origin/master git -C eapim-online/elink-online-core-jpa clean -fdx fi ''' } } stage('Verify toolchain') { steps { sh ''' set -eu echo "--- build JDK ---" java -version gradle --version echo "--- scanner JDK ---" if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then echo "JDK 17 이 없다: $JAVA_HOME_SCANNER" echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것." exit 1 fi "$JAVA_HOME_SCANNER/bin/java" -version ''' } } // clean 을 반드시 붙인다. build/generated 잔여 산출물이 남으면 // MapStruct Impl / QueryDSL Q클래스가 중복 생성되어 컴파일이 깨진다. stage('Compile') { steps { sh 'gradle clean classes testClasses --no-daemon' } } stage('Test') { when { expression { return params.RUN_TESTS } } steps { sh 'gradle test --no-daemon' } post { always { junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml' } } } // sonar.java.libraries 용 클래스패스 덤프. // 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다. stage('Export analysis classpath') { steps { catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') { sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon' } } } stage('SonarQube analysis') { steps { script { def scannerHome = tool name: env.SONAR_SCANNER_TOOL, type: 'hudson.plugins.sonar.SonarRunnerInstallation' withSonarQubeEnv(env.SONAR_ENV_NAME) { withEnv(["SONAR_SCANNER_HOME=${scannerHome}"]) { sh ''' set -eu # 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리) export JAVA_HOME="$JAVA_HOME_SCANNER" export PATH="$JAVA_HOME/bin:$PATH" # 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다. # 플러그인 버전에 따라 SONAR_AUTH_TOKEN 만 주입되는 경우가 있다. if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then export SONAR_TOKEN="$SONAR_AUTH_TOKEN" fi LIBS="" if [ -f build/sonar/java-libraries.txt ]; then LIBS=$(cat build/sonar/java-libraries.txt) else echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하" fi TEST_LIBS="" if [ -f build/sonar/java-test-libraries.txt ]; then TEST_LIBS=$(cat build/sonar/java-test-libraries.txt) fi JUNIT_ARG="" if [ -d build/test-results/test ]; then JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test" fi "$SONAR_SCANNER_HOME/bin/sonar-scanner" \ -Dsonar.projectVersion="${BUILD_NUMBER}" \ -Dsonar.java.libraries="$LIBS" \ -Dsonar.java.test.libraries="$TEST_LIBS" \ $JUNIT_ARG ''' } } } } } // SonarQube 웹훅으로 게이트 결과를 받아 판정한다. // 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다. stage('Quality Gate') { steps { script { def qg = null try { timeout(time: 15, unit: 'MINUTES') { qg = waitForQualityGate abortPipeline: false } } catch (err) { unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}") return } if (qg == null) { unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.') } else if (qg.status != 'OK') { unstable("Quality Gate ${qg.status}") } else { echo 'Quality Gate OK' } } } } } post { always { archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt' script { def report = '.scannerwork/report-task.txt' if (fileExists(report)) { def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') } if (url) { echo "SonarQube: ${url.substring('dashboardUrl='.length())}" } } } } } }