// ===================================================================== // BITFORUM Backend Jenkinsfile (IIS In-Process) — antooza 환경(CTL / 192.168.0.201) 배포 // - Solution: Backend.slnx (.NET 10) // - Components: Web.Api (IIS bitforum-api), Admin (IIS bitforum-admin) // ※ MailWorker 없음 — bitforum 은 IMailService 로 인라인 발송 (별도 워커 미포팅) // - Agent: CTL-Window-Server (antooza 와 동일한 Jenkins inbound agent, VM100 / .201) // - Branches: main / dev -> dev 환경 배포 // others -> build/test only // - Selective: AUTO_DETECT (default) via git diff, or manual checkboxes // - Deploy model: IIS In-Process AppPool + 백업/롤백 (antooza Jenkinsfile 패턴 그대로) // ===================================================================== pipeline { agent { label 'CTL-Window-Server' } options { timestamps() disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '20')) } parameters { booleanParam(name: 'AUTO_DETECT', defaultValue: true, description: 'true: git diff로 변경된 컴포넌트만 자동 배포 (아래 체크박스 무시)') booleanParam(name: 'DEPLOY_API', defaultValue: true) booleanParam(name: 'DEPLOY_ADMIN', defaultValue: true) } environment { DOTNET_CLI_TELEMETRY_OPTOUT = '1' DOTNET_NOLOGO = '1' JAVA_TOOL_OPTIONS = '-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8' // Deployment roots (CTL-Window-Server) — 서버 관례 C:\workspace\IIS\ IIS_ROOT = 'C:\\workspace\\IIS' IIS_RELEASES = 'C:\\workspace\\IIS\\_releases' // Project paths (relative to Backend/ working dir on agent) SLN_FILE = 'Backend.slnx' API_PROJ = 'Web.Api\\Web.Api.csproj' ADMIN_PROJ = 'Admin\\Admin.csproj' // EF Core EF_CTX_PROJ = 'Infrastructure' EF_START_PROJ = 'Admin' // Change detection paths SHARED_PATHS = 'Application/,Domain/,Infrastructure/,SharedKernel/,Directory.Build.props,Directory.Packages.props,global.json,Backend.slnx' API_PATHS = 'Web.Api/' ADMIN_PATHS = 'Admin/' // Health check (loopback + Host header to hit the right IIS site binding) API_HEALTH_HOST = 'api.bitforum.io' } stages { stage('Locate workdir') { steps { script { // Multibranch checkout drops repo root. Solution lives in Backend/. if (fileExists('Backend\\Backend.slnx')) { env.WORK_SUBDIR = 'Backend' } else if (fileExists('Backend.slnx')) { env.WORK_SUBDIR = '.' } else { error 'Cannot locate Backend.slnx in checkout.' } echo "Working subdirectory = '${env.WORK_SUBDIR}'" } } } stage('Detect changes') { steps { script { def doApi = params.DEPLOY_API def doAdmin = params.DEPLOY_ADMIN if (params.AUTO_DETECT) { def lastSha = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT if (!lastSha) { echo 'No previous successful build — deploying all components.' doApi = doAdmin = true } else { def diffOut = bat(returnStdout: true, script: "@git diff --name-only ${lastSha} HEAD 2>nul").trim() if (!diffOut) { echo "No diff since last successful build (${lastSha}) — nothing to deploy." doApi = doAdmin = false } else { echo "Changed files since ${lastSha}:\n${diffOut}" def files = diffOut.split('\r?\n').collect { it.replace('\\', '/') } if (env.WORK_SUBDIR == 'Backend') { files = files.findAll { it.startsWith('Backend/') } .collect { it.substring('Backend/'.length()) } } def matchAny = { String csv -> def patterns = csv.split(',').collect { it.trim() } files.any { f -> patterns.any { p -> f.startsWith(p) || f == p } } } def sharedChanged = matchAny(env.SHARED_PATHS) doApi = sharedChanged || matchAny(env.API_PATHS) doAdmin = sharedChanged || matchAny(env.ADMIN_PATHS) } } } env.DO_API = doApi.toString() env.DO_ADMIN = doAdmin.toString() echo "Deploy plan -> API:${doApi} ADMIN:${doAdmin}" if (!doApi && !doAdmin) { currentBuild.result = 'NOT_BUILT' error('No components to deploy.') } } } } stage('Clean') { steps { dir(env.WORK_SUBDIR) { bat 'if exist publish rmdir /s /q publish' } } } stage('Restore') { steps { dir(env.WORK_SUBDIR) { bat 'chcp 65001 >NUL && dotnet restore %SLN_FILE%' } } } stage('Build') { steps { dir(env.WORK_SUBDIR) { bat 'chcp 65001 >NUL && dotnet build %SLN_FILE% -c Release --no-restore' } } } stage('Publish') { parallel { stage('api') { when { expression { env.DO_API == 'true' } } steps { dir(env.WORK_SUBDIR) { bat 'chcp 65001 >NUL && dotnet publish %API_PROJ% -c Release -o publish\\api --no-build' } } } stage('admin') { when { expression { env.DO_ADMIN == 'true' } } steps { dir(env.WORK_SUBDIR) { bat 'chcp 65001 >NUL && dotnet publish %ADMIN_PROJ% -c Release -o publish\\admin --no-build' } } } } } stage('DB Migration') { when { expression { (!env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev']) && (env.DO_API == 'true' || env.DO_ADMIN == 'true') } } steps { dir(env.WORK_SUBDIR) { withCredentials([string(credentialsId: 'bitforum-db-connection-dev', variable: 'CONN')]) { bat 'chcp 65001 >NUL && dotnet tool restore --tool-manifest dotnet-tools.json' withEnv([ "ConnectionStrings__DefaultConnection=${CONN}", "ConnectionStrings__IdentityDbContextConnection=${CONN}" ]) { // EF Core CLI: `-c` = `--context` (NOT --configuration). --configuration Release 명시. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context AppDbContext --configuration Release --no-build" bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context IdentityDbContext --configuration Release --no-build" } } } } } stage('Inject Config') { when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } } steps { dir(env.WORK_SUBDIR) { script { // ⚠️ Secret File 은 committed appsettings.Production.json 을 통째로 대체함. // Encryption:Keys / ConnectionStrings / Redis / Jwt 등 필수 섹션을 secret 에 모두 포함할 것. if (env.DO_API == 'true') { withCredentials([file(credentialsId: 'DEV-BITFORUM-API', variable: 'CFG')]) { bat 'copy /Y "%CFG%" publish\\api\\appsettings.Production.json' } } if (env.DO_ADMIN == 'true') { withCredentials([file(credentialsId: 'DEV-BITFORUM-ADMIN', variable: 'CFG')]) { bat 'copy /Y "%CFG%" publish\\admin\\appsettings.Production.json' } } } } } } stage('Deploy') { when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } } parallel { stage('api') { when { expression { env.DO_API == 'true' } } steps { dir(env.WORK_SUBDIR) { script { deployIIS('bitforum-api', 'bitforum-api', 'api', 'bitforum-api', env.API_HEALTH_HOST, true) } } } } stage('admin') { when { expression { env.DO_ADMIN == 'true' } } steps { dir(env.WORK_SUBDIR) { // Admin 은 /health 엔드포인트가 없어서 AppPool Started 만 검증 script { deployIIS('bitforum-admin', 'bitforum-admin', 'admin', 'bitforum-admin', null, false) } } } } } } } post { success { echo "Build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" } failure { echo 'Build failed - check stage logs' } } } // ==== Helpers ================================================================= // IIS In-Process 배포. // siteName : Jenkins 로그용 라벨 (예: bitforum-api) // appPoolName : IIS AppPool 이름 (예: bitforum-api) — Stop-WebAppPool / Start-WebAppPool 대상 // srcSubdir : publish 하위 디렉토리 (예: api, admin) — `publish\` 에서 복사 // destFolder : C:\workspace\IIS\ (bitforum-api, bitforum-admin) // healthHost : Host 헤더로 보낼 도메인 (null이면 health 생략) // checkHealth : true면 /health 200 확인, false면 AppPool Started 만 확인 def deployIIS(siteName, appPoolName, srcSubdir, destFolder, healthHost, checkHealth) { powershell """ \$ErrorActionPreference = 'Stop' Import-Module WebAdministration \$ts = Get-Date -Format 'yyyyMMdd-HHmmss' \$dest = Join-Path '${env.IIS_ROOT}' '${destFolder}' \$backup = Join-Path '${env.IIS_RELEASES}' ('${siteName}-' + \$ts) if (-not (Test-Path '${env.IIS_RELEASES}')) { New-Item -ItemType Directory -Path '${env.IIS_RELEASES}' -Force | Out-Null } Write-Host "[${siteName}] Stopping AppPool '${appPoolName}'..." if ((Get-WebAppPoolState -Name '${appPoolName}').Value -eq 'Started') { Stop-WebAppPool -Name '${appPoolName}' \$t = 30 while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped' -and \$t -gt 0) { Start-Sleep -Seconds 1; \$t-- } if ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped') { throw '[${siteName}] AppPool did not stop within 30s' } } Start-Sleep -Seconds 2 if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) { Write-Host "[${siteName}] Backing up current to \$backup" New-Item -ItemType Directory -Path \$backup -Force | Out-Null Move-Item (Join-Path \$dest '*') \$backup -Force } elseif (-not (Test-Path \$dest)) { New-Item -ItemType Directory -Path \$dest -Force | Out-Null } Write-Host "[${siteName}] Copying new artifacts..." Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force Write-Host "[${siteName}] Starting AppPool..." Start-WebAppPool -Name '${appPoolName}' \$t = 15 while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Started' -and \$t -gt 0) { Start-Sleep -Seconds 1; \$t-- } \$ok = \$false if ('${checkHealth}' -eq 'true') { 1..10 | ForEach-Object { Start-Sleep -Seconds 3 try { \$r = Invoke-WebRequest -Uri 'http://127.0.0.1/health' -Headers @{ Host = '${healthHost}' } -UseBasicParsing -TimeoutSec 5 if (\$r.StatusCode -eq 200) { Write-Host "[${siteName}] Health OK on attempt \$_" \$ok = \$true; return } } catch { Write-Host "[${siteName}] Health attempt \$_ failed: \$(\$_.Exception.Message)" } } } else { 1..3 | ForEach-Object { Start-Sleep -Seconds 5 \$state = (Get-WebAppPoolState -Name '${appPoolName}').Value Write-Host "[${siteName}] AppPool state: \$state" if (\$state -ne 'Started') { \$ok = \$false; return } \$ok = \$true } } if (-not \$ok) { Write-Warning "[${siteName}] Verification failed - rolling back" Stop-WebAppPool -Name '${appPoolName}' -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 if (Test-Path \$backup) { Get-ChildItem \$dest -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force Start-WebAppPool -Name '${appPoolName}' } throw '[${siteName}] deployment failed; rolled back.' } # 백업 5개만 보존 Get-ChildItem '${env.IIS_RELEASES}' -Directory -ErrorAction SilentlyContinue | Where-Object { \$_.Name -like '${siteName}-*' } | Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | Remove-Item -Recurse -Force """ }