Jenkinsfile 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. // =====================================================================
  2. // BITFORUM Backend Jenkinsfile (IIS In-Process) — antooza 환경(CTL / 192.168.0.201) 배포
  3. // - Solution: Backend.slnx (.NET 10)
  4. // - Components: Web.Api (IIS bitforum-api), Admin (IIS bitforum-admin)
  5. // ※ MailWorker 없음 — bitforum 은 IMailService 로 인라인 발송 (별도 워커 미포팅)
  6. // - Agent: CTL-Window-Server (antooza 와 동일한 Jenkins inbound agent, VM100 / .201)
  7. // - Branches: main / dev -> dev 환경 배포
  8. // others -> build/test only
  9. // - Selective: AUTO_DETECT (default) via git diff, or manual checkboxes
  10. // - Deploy model: IIS In-Process AppPool + 백업/롤백 (antooza Jenkinsfile 패턴 그대로)
  11. // =====================================================================
  12. pipeline {
  13. agent { label 'CTL-Window-Server' }
  14. options {
  15. timestamps()
  16. disableConcurrentBuilds()
  17. buildDiscarder(logRotator(numToKeepStr: '20'))
  18. }
  19. parameters {
  20. booleanParam(name: 'AUTO_DETECT', defaultValue: true,
  21. description: 'true: git diff로 변경된 컴포넌트만 자동 배포 (아래 체크박스 무시)')
  22. booleanParam(name: 'DEPLOY_API', defaultValue: true)
  23. booleanParam(name: 'DEPLOY_ADMIN', defaultValue: true)
  24. }
  25. environment {
  26. DOTNET_CLI_TELEMETRY_OPTOUT = '1'
  27. DOTNET_NOLOGO = '1'
  28. JAVA_TOOL_OPTIONS = '-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8'
  29. // Deployment roots (CTL-Window-Server) — 서버 관례 C:\workspace\IIS\<site>
  30. IIS_ROOT = 'C:\\workspace\\IIS'
  31. IIS_RELEASES = 'C:\\workspace\\IIS\\_releases'
  32. // Project paths (relative to Backend/ working dir on agent)
  33. SLN_FILE = 'Backend.slnx'
  34. API_PROJ = 'Web.Api\\Web.Api.csproj'
  35. ADMIN_PROJ = 'Admin\\Admin.csproj'
  36. // EF Core
  37. EF_CTX_PROJ = 'Infrastructure'
  38. EF_START_PROJ = 'Admin'
  39. // Change detection paths
  40. SHARED_PATHS = 'Application/,Domain/,Infrastructure/,SharedKernel/,Directory.Build.props,Directory.Packages.props,global.json,Backend.slnx'
  41. API_PATHS = 'Web.Api/'
  42. ADMIN_PATHS = 'Admin/'
  43. // Health check (loopback + Host header to hit the right IIS site binding)
  44. API_HEALTH_HOST = 'api.bitforum.io'
  45. }
  46. stages {
  47. stage('Locate workdir') {
  48. steps {
  49. script {
  50. // Multibranch checkout drops repo root. Solution lives in Backend/.
  51. if (fileExists('Backend\\Backend.slnx')) {
  52. env.WORK_SUBDIR = 'Backend'
  53. } else if (fileExists('Backend.slnx')) {
  54. env.WORK_SUBDIR = '.'
  55. } else {
  56. error 'Cannot locate Backend.slnx in checkout.'
  57. }
  58. echo "Working subdirectory = '${env.WORK_SUBDIR}'"
  59. }
  60. }
  61. }
  62. stage('Detect changes') {
  63. steps {
  64. script {
  65. def doApi = params.DEPLOY_API
  66. def doAdmin = params.DEPLOY_ADMIN
  67. if (params.AUTO_DETECT) {
  68. def lastSha = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT
  69. if (!lastSha) {
  70. echo 'No previous successful build — deploying all components.'
  71. doApi = doAdmin = true
  72. } else {
  73. def diffOut = bat(returnStdout: true,
  74. script: "@git diff --name-only ${lastSha} HEAD 2>nul").trim()
  75. if (!diffOut) {
  76. echo "No diff since last successful build (${lastSha}) — nothing to deploy."
  77. doApi = doAdmin = false
  78. } else {
  79. echo "Changed files since ${lastSha}:\n${diffOut}"
  80. def files = diffOut.split('\r?\n').collect { it.replace('\\', '/') }
  81. if (env.WORK_SUBDIR == 'Backend') {
  82. files = files.findAll { it.startsWith('Backend/') }
  83. .collect { it.substring('Backend/'.length()) }
  84. }
  85. def matchAny = { String csv ->
  86. def patterns = csv.split(',').collect { it.trim() }
  87. files.any { f -> patterns.any { p -> f.startsWith(p) || f == p } }
  88. }
  89. def sharedChanged = matchAny(env.SHARED_PATHS)
  90. doApi = sharedChanged || matchAny(env.API_PATHS)
  91. doAdmin = sharedChanged || matchAny(env.ADMIN_PATHS)
  92. }
  93. }
  94. }
  95. env.DO_API = doApi.toString()
  96. env.DO_ADMIN = doAdmin.toString()
  97. echo "Deploy plan -> API:${doApi} ADMIN:${doAdmin}"
  98. if (!doApi && !doAdmin) {
  99. currentBuild.result = 'NOT_BUILT'
  100. error('No components to deploy.')
  101. }
  102. }
  103. }
  104. }
  105. stage('Clean') {
  106. steps {
  107. dir(env.WORK_SUBDIR) {
  108. bat 'if exist publish rmdir /s /q publish'
  109. }
  110. }
  111. }
  112. stage('Restore') {
  113. steps {
  114. dir(env.WORK_SUBDIR) {
  115. bat 'chcp 65001 >NUL && dotnet restore %SLN_FILE% --disable-build-servers'
  116. }
  117. }
  118. }
  119. stage('Build') {
  120. steps {
  121. dir(env.WORK_SUBDIR) {
  122. bat 'set MSBUILDDISABLENODEREUSE=1 && chcp 65001 >NUL && dotnet build %SLN_FILE% -c Release --no-restore --disable-build-servers'
  123. }
  124. }
  125. }
  126. stage('Publish') {
  127. parallel {
  128. stage('api') {
  129. when { expression { env.DO_API == 'true' } }
  130. steps {
  131. dir(env.WORK_SUBDIR) {
  132. bat 'chcp 65001 >NUL && dotnet publish %API_PROJ% -c Release -o publish\\api --no-build'
  133. }
  134. }
  135. }
  136. stage('admin') {
  137. when { expression { env.DO_ADMIN == 'true' } }
  138. steps {
  139. dir(env.WORK_SUBDIR) {
  140. bat 'chcp 65001 >NUL && dotnet publish %ADMIN_PROJ% -c Release -o publish\\admin --no-build'
  141. }
  142. }
  143. }
  144. }
  145. }
  146. stage('DB Migration') {
  147. when {
  148. expression { (!env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev']) && (env.DO_API == 'true' || env.DO_ADMIN == 'true') }
  149. }
  150. steps {
  151. dir(env.WORK_SUBDIR) {
  152. withCredentials([string(credentialsId: 'bitforum-db-connection-dev', variable: 'CONN')]) {
  153. bat 'chcp 65001 >NUL && dotnet tool restore --tool-manifest dotnet-tools.json'
  154. withEnv([
  155. "ConnectionStrings__DefaultConnection=${CONN}",
  156. "ConnectionStrings__IdentityDbContextConnection=${CONN}",
  157. "Redis__DefaultConnection=localhost:6379,abortConnect=false,connectTimeout=500",
  158. "Encryption__CurrentKeyVersion=1",
  159. "Encryption__Keys__V1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
  160. ]) {
  161. // EF Core CLI: `-c` = `--context` (NOT --configuration). --configuration Release 명시.
  162. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context AppDbContext --configuration Release --no-build"
  163. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context IdentityDbContext --configuration Release --no-build"
  164. }
  165. }
  166. }
  167. }
  168. }
  169. stage('Inject Config') {
  170. when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
  171. steps {
  172. dir(env.WORK_SUBDIR) {
  173. script {
  174. // ⚠️ Secret File 은 committed appsettings.Production.json 을 통째로 대체함.
  175. // Encryption:Keys / ConnectionStrings / Redis / Jwt 등 필수 섹션을 secret 에 모두 포함할 것.
  176. if (env.DO_API == 'true') {
  177. withCredentials([file(credentialsId: 'DEV-BITFORUM-API', variable: 'CFG')]) {
  178. bat 'copy /Y "%CFG%" publish\\api\\appsettings.Production.json'
  179. }
  180. }
  181. if (env.DO_ADMIN == 'true') {
  182. withCredentials([file(credentialsId: 'DEV-BITFORUM-ADMIN', variable: 'CFG')]) {
  183. bat 'copy /Y "%CFG%" publish\\admin\\appsettings.Production.json'
  184. }
  185. }
  186. }
  187. }
  188. }
  189. }
  190. stage('Deploy') {
  191. when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
  192. parallel {
  193. stage('api') {
  194. when { expression { env.DO_API == 'true' } }
  195. steps {
  196. dir(env.WORK_SUBDIR) {
  197. script { deployIIS('bitforum-api', 'bitforum-api', 'api', 'bitforum-api', env.API_HEALTH_HOST, true) }
  198. }
  199. }
  200. }
  201. stage('admin') {
  202. when { expression { env.DO_ADMIN == 'true' } }
  203. steps {
  204. dir(env.WORK_SUBDIR) {
  205. // Admin 은 /health 엔드포인트가 없어서 AppPool Started 만 검증
  206. script { deployIIS('bitforum-admin', 'bitforum-admin', 'admin', 'bitforum-admin', null, false) }
  207. }
  208. }
  209. }
  210. }
  211. }
  212. }
  213. post {
  214. success { echo "Build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" }
  215. failure { echo 'Build failed - check stage logs' }
  216. }
  217. }
  218. // ==== Helpers =================================================================
  219. // IIS In-Process 배포.
  220. // siteName : Jenkins 로그용 라벨 (예: bitforum-api)
  221. // appPoolName : IIS AppPool 이름 (예: bitforum-api) — Stop-WebAppPool / Start-WebAppPool 대상
  222. // srcSubdir : publish 하위 디렉토리 (예: api, admin) — `publish\<srcSubdir>` 에서 복사
  223. // destFolder : C:\workspace\IIS\<destFolder> (bitforum-api, bitforum-admin)
  224. // healthHost : Host 헤더로 보낼 도메인 (null이면 health 생략)
  225. // checkHealth : true면 /health 200 확인, false면 AppPool Started 만 확인
  226. def deployIIS(siteName, appPoolName, srcSubdir, destFolder, healthHost, checkHealth) {
  227. powershell """
  228. \$ErrorActionPreference = 'Stop'
  229. Import-Module WebAdministration
  230. \$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
  231. \$dest = Join-Path '${env.IIS_ROOT}' '${destFolder}'
  232. \$backup = Join-Path '${env.IIS_RELEASES}' ('${siteName}-' + \$ts)
  233. if (-not (Test-Path '${env.IIS_RELEASES}')) { New-Item -ItemType Directory -Path '${env.IIS_RELEASES}' -Force | Out-Null }
  234. Write-Host "[${siteName}] Stopping AppPool '${appPoolName}'..."
  235. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -eq 'Started') {
  236. Stop-WebAppPool -Name '${appPoolName}'
  237. \$t = 30
  238. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped' -and \$t -gt 0) {
  239. Start-Sleep -Seconds 1; \$t--
  240. }
  241. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped') {
  242. throw '[${siteName}] AppPool did not stop within 30s'
  243. }
  244. }
  245. Start-Sleep -Seconds 2
  246. if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
  247. Write-Host "[${siteName}] Backing up current to \$backup"
  248. New-Item -ItemType Directory -Path \$backup -Force | Out-Null
  249. Move-Item (Join-Path \$dest '*') \$backup -Force
  250. } elseif (-not (Test-Path \$dest)) {
  251. New-Item -ItemType Directory -Path \$dest -Force | Out-Null
  252. }
  253. Write-Host "[${siteName}] Copying new artifacts..."
  254. Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force
  255. Write-Host "[${siteName}] Starting AppPool..."
  256. Start-WebAppPool -Name '${appPoolName}'
  257. \$t = 15
  258. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Started' -and \$t -gt 0) {
  259. Start-Sleep -Seconds 1; \$t--
  260. }
  261. \$ok = \$false
  262. if ('${checkHealth}' -eq 'true') {
  263. 1..10 | ForEach-Object {
  264. Start-Sleep -Seconds 3
  265. try {
  266. \$r = Invoke-WebRequest -Uri 'http://127.0.0.1/health' -Headers @{ Host = '${healthHost}' } -UseBasicParsing -TimeoutSec 5
  267. if (\$r.StatusCode -eq 200) {
  268. Write-Host "[${siteName}] Health OK on attempt \$_"
  269. \$ok = \$true; return
  270. }
  271. } catch {
  272. Write-Host "[${siteName}] Health attempt \$_ failed: \$(\$_.Exception.Message)"
  273. }
  274. }
  275. } else {
  276. 1..3 | ForEach-Object {
  277. Start-Sleep -Seconds 5
  278. \$state = (Get-WebAppPoolState -Name '${appPoolName}').Value
  279. Write-Host "[${siteName}] AppPool state: \$state"
  280. if (\$state -ne 'Started') { \$ok = \$false; return }
  281. \$ok = \$true
  282. }
  283. }
  284. if (-not \$ok) {
  285. Write-Warning "[${siteName}] Verification failed - rolling back"
  286. Stop-WebAppPool -Name '${appPoolName}' -ErrorAction SilentlyContinue
  287. Start-Sleep -Seconds 2
  288. if (Test-Path \$backup) {
  289. Get-ChildItem \$dest -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
  290. Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
  291. Start-WebAppPool -Name '${appPoolName}'
  292. }
  293. throw '[${siteName}] deployment failed; rolled back.'
  294. }
  295. # 백업 5개만 보존
  296. Get-ChildItem '${env.IIS_RELEASES}' -Directory -ErrorAction SilentlyContinue |
  297. Where-Object { \$_.Name -like '${siteName}-*' } |
  298. Sort-Object LastWriteTime -Descending |
  299. Select-Object -Skip 5 |
  300. Remove-Item -Recurse -Force
  301. """
  302. }