// ===================================================================== // ANTOOZA Backend Jenkinsfile (IIS In-Process + WinSW Worker) // - Solution: Backend.slnx (.NET 10) // - Components: Web.Api (IIS antooza-api), Admin (IIS antooza-admin), MailWorker (WinSW antooza-mail) // - Agent: CTL-Window-Server (Jenkins inbound agent on 10.10.0.100) // - Branches: main / dev -> deploy to dev environment (antooza.com — dev 서버를 운영 도메인으로) // others -> build/test only // - Selective: AUTO_DETECT (default) via git diff, or manual checkboxes // ===================================================================== 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) booleanParam(name: 'DEPLOY_MAIL', 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' WORKER_ROOT = 'C:\\workspace\\antooza' WORKER_RELEASES = 'C:\\workspace\\antooza\\_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' MAIL_PROJ = 'MailWorker\\MailWorker.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/' MAIL_PATHS = 'MailWorker/' // Health check (loopback + Host header to hit the right IIS site binding) // IIS HTTP binding은 api.antooza.com. dev 서버를 운영 도메인으로 사용(dev- 접두사 제거) — Host 헤더가 사이트 바인딩과 일치해야 health 통과. API_HEALTH_HOST = 'api.antooza.com' } stages { stage('Locate workdir') { steps { script { // Multibranch checkout drops repo root. Solution lives in Backend/. // dir('') would throw — use '.' as the no-prefix marker instead. 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 def doMail = params.DEPLOY_MAIL if (params.AUTO_DETECT) { def lastSha = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT if (!lastSha) { echo 'No previous successful build — deploying all components.' doApi = doAdmin = doMail = 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 = doMail = 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) doMail = sharedChanged || matchAny(env.MAIL_PATHS) } } } env.DO_API = doApi.toString() env.DO_ADMIN = doAdmin.toString() env.DO_MAIL = doMail.toString() echo "Deploy plan -> API:${doApi} ADMIN:${doAdmin} MAIL:${doMail}" if (!doApi && !doAdmin && !doMail) { 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('mail') { when { expression { env.DO_MAIL == 'true' } } steps { dir(env.WORK_SUBDIR) { bat 'chcp 65001 >NUL && dotnet publish %MAIL_PROJ% -c Release -o publish\\mail --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: 'antooza-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` is short for `--context`, NOT `--configuration`. Use `--configuration Release` explicitly. 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 { if (env.DO_API == 'true') { withCredentials([file(credentialsId: 'DEV-ANTOOZA-API', variable: 'CFG')]) { bat 'copy /Y "%CFG%" publish\\api\\appsettings.Production.json' } } if (env.DO_ADMIN == 'true') { withCredentials([file(credentialsId: 'DEV-ANTOOZA-ADMIN', variable: 'CFG')]) { bat 'copy /Y "%CFG%" publish\\admin\\appsettings.Production.json' } } if (env.DO_MAIL == 'true') { withCredentials([file(credentialsId: 'DEV-ANTOOZA-MAIL', variable: 'CFG')]) { bat 'copy /Y "%CFG%" publish\\mail\\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('antooza-api', 'antooza-api', 'api', 'antooza-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('antooza-admin', 'antooza-admin', 'admin', 'antooza-admin', null, false) } } } } stage('mail') { when { expression { env.DO_MAIL == 'true' } } steps { dir(env.WORK_SUBDIR) { script { deployWinSWWorker('antooza-mail', 'mail') } } } } } } } post { success { echo "Build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" } failure { echo 'Build failed - check stage logs' } } } // ==== Helpers ================================================================= // IIS In-Process 배포. // siteName : Jenkins 로그용 라벨 (예: antooza-api) // appPoolName : IIS AppPool 이름 (예: antooza-api) — Stop-WebAppPool / Start-WebAppPool 대상 // srcSubdir : publish 하위 디렉토리 (예: api, admin) — `publish\` 에서 복사 // destFolder : C:\workspace\IIS\ (antooza-api, antooza-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 """ } // WinSW Worker 배포 (HTTP 없음, 프로세스 살아있는지 확인) def deployWinSWWorker(svcName, srcSubdir) { powershell """ \$ErrorActionPreference = 'Stop' \$ts = Get-Date -Format 'yyyyMMdd-HHmmss' \$dest = Join-Path '${env.WORKER_ROOT}' '${srcSubdir}' \$backup = Join-Path '${env.WORKER_RELEASES}' ('${svcName}-' + \$ts) if (-not (Test-Path '${env.WORKER_RELEASES}')) { New-Item -ItemType Directory -Path '${env.WORKER_RELEASES}' -Force | Out-Null } Write-Host "[${svcName}] Stopping service..." Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 \$keep = @('${svcName}.exe', '${svcName}.xml', '${svcName}.err.log', '${svcName}.out.log', '${svcName}.wrapper.log') if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) { Write-Host "[${svcName}] Backing up current to \$backup" New-Item -ItemType Directory -Path \$backup -Force | Out-Null Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } | Move-Item -Destination \$backup -Force } elseif (-not (Test-Path \$dest)) { New-Item -ItemType Directory -Path \$dest -Force | Out-Null } Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force # First-deploy auto-install: register WinSW service if not yet present if (-not (Get-Service ${svcName} -ErrorAction SilentlyContinue)) { Write-Host "[${svcName}] Service not registered — running '${svcName}.exe install'" \$installer = Join-Path \$dest '${svcName}.exe' if (-not (Test-Path \$installer)) { throw "[${svcName}] WinSW binary not found at \$installer (expected alongside ${svcName}.xml)" } & \$installer install if (\$LASTEXITCODE -ne 0) { throw "[${svcName}] install failed with exit code \$LASTEXITCODE" } Start-Sleep -Seconds 2 } Start-Service ${svcName} \$ok = \$true 1..3 | ForEach-Object { Start-Sleep -Seconds 5 \$status = (Get-Service ${svcName}).Status Write-Host "[${svcName}] Status: \$status" if (\$status -ne 'Running') { \$ok = \$false; return } } if (-not \$ok) { Write-Warning "[${svcName}] Worker did not stay Running - rolling back" Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue if (Test-Path \$backup) { Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force Start-Service ${svcName} } throw "${svcName} failed to start; rolled back." } Get-ChildItem '${env.WORKER_RELEASES}' -Directory -ErrorAction SilentlyContinue | Where-Object { \$_.Name -like '${svcName}-*' } | Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | Remove-Item -Recurse -Force """ }