From 9636bd2abf7e92df64befa7de2d408f9b318525c Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 11:07:41 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20?= =?UTF-8?q?=EC=95=B1=20=EC=B6=94=EA=B0=80=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + .../service/DesktopAppBuildService.java | 149 ++++++++++++++++-- backend/src/main/resources/application.yml | 2 +- scripts/configure-desktop-jenkins-backend.sh | 75 +++++++++ scripts/server-install-desktop-jenkins.sh | 7 + scripts/verify-desktop-jenkins-trigger.sh | 88 +++++++++++ 6 files changed, 309 insertions(+), 18 deletions(-) create mode 100755 scripts/configure-desktop-jenkins-backend.sh create mode 100755 scripts/verify-desktop-jenkins-trigger.sh diff --git a/.env.example b/.env.example index cd2ead5..0989369 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,9 @@ BACKEND_PORT=8080 # Frontend (외부 노출 포트) FRONTEND_PORT=80 + +# Desktop Jenkins (웹 UI 「앱 빌드」 — exdev 서버 .env에 설정) +# GC_DESKTOP_JENKINS_URL=http://host.docker.internal:8090 +# GC_DESKTOP_JENKINS_USER=aidev +# GC_DESKTOP_JENKINS_TOKEN=jenkins-api-token-here +# GC_DESKTOP_JENKINS_TRIGGER_TOKEN=goldenchart-desktop-build diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java index dd2a273..c74a21e 100644 --- a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java @@ -1,6 +1,7 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.DesktopAppBuildStatusDto; import com.goldenchart.dto.DesktopAppBuildTriggerDto; import lombok.RequiredArgsConstructor; @@ -15,9 +16,16 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti import org.springframework.web.server.ResponseStatusException; import org.springframework.web.util.UriUtils; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Locale; import java.util.Map; @@ -29,6 +37,7 @@ public class DesktopAppBuildService { private final WebClient.Builder webClientBuilder; private final RolePermissionService rolePermissionService; + private final ObjectMapper objectMapper; @Value("${goldenchart.desktop-app.jenkins.enabled:true}") private boolean jenkinsEnabled; @@ -195,23 +204,120 @@ public class DesktopAppBuildService { } private void triggerJenkinsBuild() { - WebClient client = jenkinsClient(); - String buildUrl = buildTriggerUrl(); - Map crumb = fetchCrumbOptional(client); - try { - postBuild(client, buildUrl, crumb); - } catch (WebClientResponseException.Forbidden e) { - if (crumb != null) throw e; - postBuild(client, buildUrl, fetchCrumb(client)); + if (hasBasicAuth()) { + try { + triggerJenkinsBuildWithSession(); + return; + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.warn("[desktop-build] session trigger failed: {}", e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "Jenkins 빌드 요청 실패: " + rootMessage(e)); + } } + triggerJenkinsBuildWebClient(); } - private String buildTriggerUrl() { - String url = jobApiPath() + "/build"; - if (hasTriggerToken()) { - url += "?token=" + UriUtils.encodeQueryParam(jenkinsTriggerToken.trim(), StandardCharsets.UTF_8); + /** Jenkins CSRF는 세션 쿠키 + crumb 조합이 필요 — java.net.http CookieManager 사용 */ + private void triggerJenkinsBuildWithSession() throws Exception { + CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + HttpClient httpClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .connectTimeout(Duration.ofSeconds(15)) + .build(); + String base = normalizeBase(jenkinsUrl); + String auth = Base64.getEncoder().encodeToString( + (jenkinsUser.trim() + ":" + jenkinsToken.trim()).getBytes(StandardCharsets.UTF_8)); + + String crumbField = "Jenkins-Crumb"; + String crumbValue = null; + HttpRequest crumbReq = HttpRequest.newBuilder() + .uri(URI.create(base + "/crumbIssuer/api/json")) + .header("Authorization", "Basic " + auth) + .header("Accept", MediaType.APPLICATION_JSON_VALUE) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + HttpResponse crumbResp = httpClient.send(crumbReq, HttpResponse.BodyHandlers.ofString()); + if (crumbResp.statusCode() == 200 && StringUtils.hasText(crumbResp.body())) { + JsonNode node = objectMapper.readTree(crumbResp.body()); + crumbField = node.path("crumbRequestField").asText("Jenkins-Crumb"); + crumbValue = textOrNull(node.path("crumb")); } - return url; + + int lastCode = 0; + for (String path : buildTriggerUrlCandidates()) { + HttpRequest.Builder req = HttpRequest.newBuilder() + .uri(URI.create(base + path)) + .header("Authorization", "Basic " + auth) + .header("Content-Type", MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .timeout(Duration.ofSeconds(20)) + .POST(HttpRequest.BodyPublishers.noBody()); + if (StringUtils.hasText(crumbValue)) { + req.header(crumbField, crumbValue); + } + HttpResponse resp = httpClient.send(req.build(), HttpResponse.BodyHandlers.discarding()); + lastCode = resp.statusCode(); + if (lastCode >= 200 && lastCode < 400) { + return; + } + } + if (lastCode == 401 || lastCode == 403) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, jenkinsHttpErrorForCode(lastCode)); + } + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Jenkins HTTP " + lastCode); + } + + private void triggerJenkinsBuildWebClient() { + WebClient client = jenkinsClient(); + WebClientResponseException lastAuthError = null; + + for (String buildUrl : buildTriggerUrlCandidates()) { + Map crumb = fetchCrumbOptional(client); + try { + postBuild(client, buildUrl, crumb); + return; + } catch (WebClientResponseException.Forbidden e) { + lastAuthError = e; + if (crumb == null) { + try { + postBuild(client, buildUrl, fetchCrumb(client)); + return; + } catch (WebClientResponseException.Forbidden e2) { + lastAuthError = e2; + } + } + } + } + + if (lastAuthError != null) { + throw lastAuthError; + } + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Jenkins 빌드 트리거 URL을 구성할 수 없습니다."); + } + + /** 인증+토큰 조합을 순서대로 시도 (대부분의 Jenkins는 USER/TOKEN 없이 token만으로는 403). */ + private List buildTriggerUrlCandidates() { + String base = jobApiPath() + "/build"; + String tokenQuery = hasTriggerToken() + ? "?token=" + UriUtils.encodeQueryParam(jenkinsTriggerToken.trim(), StandardCharsets.UTF_8) + : ""; + + List urls = new ArrayList<>(); + if (hasBasicAuth() && hasTriggerToken()) { + urls.add(base + tokenQuery); + } + if (hasBasicAuth()) { + urls.add(base); + } + if (hasTriggerToken()) { + urls.add(base + tokenQuery); + } + if (urls.isEmpty()) { + urls.add(base); + } + return urls; } private Map fetchCrumbOptional(WebClient client) { @@ -293,15 +399,24 @@ public class DesktopAppBuildService { return t == null || t.isBlank() ? null : t; } - private static String jenkinsHttpError(WebClientResponseException e) { - int code = e.getStatusCode().value(); + private String jenkinsHttpError(WebClientResponseException e) { + return jenkinsHttpErrorForCode(e.getStatusCode().value()); + } + + private String jenkinsHttpErrorForCode(int code) { if (code == 401 || code == 403) { - return "Jenkins 인증 실패 — GC_DESKTOP_JENKINS_TRIGGER_TOKEN 또는 USER/TOKEN·Job 원격 트리거 토큰을 확인하세요."; + if (!hasBasicAuth()) { + return "Jenkins 인증 실패 — 서버 .env에 GC_DESKTOP_JENKINS_USER·GC_DESKTOP_JENKINS_TOKEN(Jenkins API Token)을 설정하고 " + + "GC_DESKTOP_JENKINS_TRIGGER_TOKEN(기본 goldenchart-desktop-build)과 Job authToken이 일치하는지 확인하세요. " + + "설정: ./scripts/configure-desktop-jenkins-backend.sh"; + } + return "Jenkins 인증 실패 — GC_DESKTOP_JENKINS_USER/TOKEN·GC_DESKTOP_JENKINS_TRIGGER_TOKEN·Job authToken을 확인하세요. " + + "Job 재등록: ./scripts/server-install-desktop-jenkins.sh"; } if (code == 404) { return "Jenkins Job을 찾을 수 없습니다 — ./scripts/server-install-desktop-jenkins.sh 로 Job을 등록하세요."; } - return "Jenkins HTTP " + code + ": " + e.getStatusText(); + return "Jenkins HTTP " + code; } private static String rootMessage(Throwable e) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a127efd..35fb621 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -124,7 +124,7 @@ goldenchart: job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} user: ${GC_DESKTOP_JENKINS_USER:} token: ${GC_DESKTOP_JENKINS_TOKEN:} - trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:} + trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:goldenchart-desktop-build} cors: allowed-origins: - http://localhost:5173 diff --git a/scripts/configure-desktop-jenkins-backend.sh b/scripts/configure-desktop-jenkins-backend.sh new file mode 100755 index 0000000..faf20eb --- /dev/null +++ b/scripts/configure-desktop-jenkins-backend.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# exdev 서버 — backend Docker가 Jenkins 빌드를 트리거하도록 .env 설정 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}" + +JENKINS_URL="${GC_DESKTOP_JENKINS_URL:-http://127.0.0.1:8090}" +JENKINS_JOB="${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline}" +JENKINS_TRIGGER_TOKEN="${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:-goldenchart-desktop-build}" + +log() { echo "[configure-jenkins] $*"; } + +upsert_env() { + local key="$1" val="$2" + if [[ -f "$ENV_FILE" ]] && grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" + else + sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" + fi + else + echo "${key}=${val}" >> "$ENV_FILE" + fi +} + +log "GoldenChart backend → Jenkins 연동 설정" +log "env file: $ENV_FILE" + +if [[ -z "${GC_DESKTOP_JENKINS_USER:-}" ]]; then + read -r -p "Jenkins 사용자명 (예: aidev): " GC_DESKTOP_JENKINS_USER +fi +if [[ -z "${GC_DESKTOP_JENKINS_TOKEN:-}" ]]; then + echo "" + echo "Jenkins API Token 생성:" + echo " 1) ${JENKINS_URL} → 로그인" + echo " 2) 사용자 이름 클릭 → Configure → API Token → Add new Token → Generate" + echo "" + read -r -s -p "생성한 API Token: " GC_DESKTOP_JENKINS_TOKEN + echo "" +fi + +if [[ -z "$GC_DESKTOP_JENKINS_USER" || -z "$GC_DESKTOP_JENKINS_TOKEN" ]]; then + echo "ERROR: USER/TOKEN이 필요합니다." >&2 + exit 1 +fi + +mkdir -p "$(dirname "$ENV_FILE")" +touch "$ENV_FILE" + +upsert_env "GC_DESKTOP_JENKINS_ENABLED" "true" +upsert_env "GC_DESKTOP_JENKINS_URL" "http://host.docker.internal:8090" +upsert_env "GC_DESKTOP_JENKINS_JOB" "$JENKINS_JOB" +upsert_env "GC_DESKTOP_JENKINS_USER" "$GC_DESKTOP_JENKINS_USER" +upsert_env "GC_DESKTOP_JENKINS_TOKEN" "$GC_DESKTOP_JENKINS_TOKEN" +upsert_env "GC_DESKTOP_JENKINS_TRIGGER_TOKEN" "$JENKINS_TRIGGER_TOKEN" + +log "Job authToken 동기화…" +GC_DESKTOP_JENKINS_TRIGGER_TOKEN="$JENKINS_TRIGGER_TOKEN" \ + "$SCRIPT_DIR/server-install-desktop-jenkins.sh" + +log "트리거 검증…" +export GC_DESKTOP_JENKINS_URL="$JENKINS_URL" +export GC_DESKTOP_JENKINS_USER GC_DESKTOP_JENKINS_TOKEN GC_DESKTOP_JENKINS_TRIGGER_TOKEN +DRY_RUN=1 "$SCRIPT_DIR/verify-desktop-jenkins-trigger.sh" + +if command -v docker >/dev/null 2>&1 && [[ -f "$REPO_ROOT/docker-compose.yml" ]]; then + log "backend 컨테이너 재시작…" + (cd "$REPO_ROOT" && docker compose up -d backend) + sleep 3 + log "완료. 웹 UI에서 「앱 빌드」를 다시 시도하세요." +else + log "완료. docker compose up -d backend 로 backend를 재시작하세요." +fi diff --git a/scripts/server-install-desktop-jenkins.sh b/scripts/server-install-desktop-jenkins.sh index cd8b34f..7c176b7 100755 --- a/scripts/server-install-desktop-jenkins.sh +++ b/scripts/server-install-desktop-jenkins.sh @@ -83,6 +83,13 @@ log "완료: http://127.0.0.1:8090/job/${JENKINS_JOB}/" log "원격 트리거 토큰: $JENKINS_TRIGGER_TOKEN" log "backend env: GC_DESKTOP_JENKINS_TRIGGER_TOKEN=$JENKINS_TRIGGER_TOKEN" log "" +log "Jenkins JVM (file:// bare checkout 허용, launchd plist 또는 nohup java -D):" +log " -Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true" +log "" +log "웹 UI 「앱 빌드」를 쓰려면 Jenkins API Token 필수:" +log " ./scripts/configure-desktop-jenkins-backend.sh" +log " (또는 .env에 GC_DESKTOP_JENKINS_USER / GC_DESKTOP_JENKINS_TOKEN 설정 후 docker compose up -d backend)" +log "" log "Jenkins Credentials (Secret file):" log " ID: tauri-signer-key → \$HOME/.tauri/goldenchart.key" log "" diff --git a/scripts/verify-desktop-jenkins-trigger.sh b/scripts/verify-desktop-jenkins-trigger.sh new file mode 100755 index 0000000..fcfacd4 --- /dev/null +++ b/scripts/verify-desktop-jenkins-trigger.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Jenkins desktop 빌드 트리거 연결·인증 검증 (exdev 서버에서 실행) +set -euo pipefail + +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}" + +JENKINS_URL="${GC_DESKTOP_JENKINS_URL:-http://127.0.0.1:8090}" +JENKINS_JOB="${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline}" +JENKINS_USER="${GC_DESKTOP_JENKINS_USER:-}" +JENKINS_TOKEN="${GC_DESKTOP_JENKINS_TOKEN:-}" +JENKINS_TRIGGER_TOKEN="${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:-goldenchart-desktop-build}" +DOCKER_JENKINS_URL="${DOCKER_JENKINS_URL:-http://host.docker.internal:8090}" +DRY_RUN="${DRY_RUN:-1}" + +JENKINS_URL="${JENKINS_URL%/}" +JOB_API="${JENKINS_URL}/job/${JENKINS_JOB// /%20}" + +log() { echo "[verify-jenkins] $*"; } +fail() { log "FAIL: $*"; exit 1; } + +curl_get() { + if [[ -n "$JENKINS_USER" && -n "$JENKINS_TOKEN" ]]; then + curl -sS -u "${JENKINS_USER}:${JENKINS_TOKEN}" "$@" + else + curl -sS "$@" + fi +} + +curl_post() { + local extra=() + if [[ -n "$JENKINS_USER" && -n "$JENKINS_TOKEN" ]]; then + extra=(-u "${JENKINS_USER}:${JENKINS_TOKEN}") + fi + curl -sS -o /tmp/gc-jenkins-trigger-body.txt -w "%{http_code}" "${extra[@]}" -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" "$@" +} + +log "Jenkins URL (host): $JENKINS_URL" +log "Job: $JENKINS_JOB" +log "Trigger token: $JENKINS_TRIGGER_TOKEN" +log "API user: ${JENKINS_USER:-}" +log "DRY_RUN=$DRY_RUN (실제 빌드 트리거 없음, HTTP 코드만 확인)" + +if ! curl -sf -o /dev/null "${JENKINS_URL}/login"; then + fail "Jenkins에 연결할 수 없습니다 ($JENKINS_URL). Jenkins가 8090에서 실행 중인지 확인하세요." +fi +log "OK: Jenkins login page reachable" + +if [[ -z "$JENKINS_USER" || -z "$JENKINS_TOKEN" ]]; then + log "WARN: GC_DESKTOP_JENKINS_USER/TOKEN 미설정 — 웹 UI 빌드 트리거는 대부분 403으로 실패합니다." + log " Jenkins → 사용자 → Configure → API Token 생성 후:" + log " ./scripts/configure-desktop-jenkins-backend.sh" +fi + +if [[ "$DRY_RUN" == "1" ]]; then + job_code="$(curl_get -o /tmp/gc-jenkins-job.json -w "%{http_code}" "${JOB_API}/api/json?tree=name" || true)" + log "GET job api → HTTP $job_code" + if [[ "$job_code" == "404" ]]; then + fail "Job 없음 (HTTP 404). ./scripts/server-install-desktop-jenkins.sh 실행." + fi + if [[ "$job_code" == "401" || "$job_code" == "403" ]]; then + fail "Jenkins API 인증 실패 (HTTP $job_code). GC_DESKTOP_JENKINS_USER/TOKEN 확인." + fi + if [[ ! "$job_code" =~ ^2 ]]; then + fail "Job API 오류 (HTTP $job_code)" + fi + log "OK: Job API 접근 가능 (빌드 트리거는 DRY_RUN으로 생략)" +else + log "실제 빌드 트리거 — ./scripts/trigger-desktop-jenkins-build.sh 호출" + GC_DESKTOP_JENKINS_URL="$JENKINS_URL" \ + GC_DESKTOP_JENKINS_USER="$JENKINS_USER" \ + GC_DESKTOP_JENKINS_TOKEN="$JENKINS_TOKEN" \ + GC_DESKTOP_JENKINS_TRIGGER_TOKEN="$JENKINS_TRIGGER_TOKEN" \ + "$(cd "$(dirname "$0")" && pwd)/trigger-desktop-jenkins-build.sh" +fi + +if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'gc-backend'; then + log "Docker backend → host Jenkins 연결 테스트 ($DOCKER_JENKINS_URL)" + if docker exec gc-backend wget -qO- --timeout=5 "${DOCKER_JENKINS_URL}/login" >/dev/null 2>&1; then + log "OK: gc-backend 컨테이너에서 host Jenkins 접근 가능" + else + fail "gc-backend에서 $DOCKER_JENKINS_URL 접근 불가. docker-compose extra_hosts·GC_DESKTOP_JENKINS_URL 확인 후 docker compose up -d backend" + fi +else + log "SKIP: gc-backend 컨테이너 없음 — Docker 연결 테스트 생략" +fi + +log "검증 완료"