| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- // =====================================================================
- // 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\<site>
- 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% --disable-build-servers'
- }
- }
- }
- stage('Build') {
- steps {
- dir(env.WORK_SUBDIR) {
- bat 'set MSBUILDDISABLENODEREUSE=1 && chcp 65001 >NUL && dotnet build %SLN_FILE% -c Release --no-restore --disable-build-servers'
- }
- }
- }
- 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}",
- "Redis__DefaultConnection=localhost:6379,abortConnect=false,connectTimeout=500",
- "Encryption__CurrentKeyVersion=1",
- "Encryption__Keys__V1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
- ]) {
- // 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\<srcSubdir>` 에서 복사
- // destFolder : C:\workspace\IIS\<destFolder> (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
- """
- }
|