Jenkinsfile 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. // =====================================================================
  2. // ANTOOZA Backend Jenkinsfile (IIS In-Process + WinSW Worker)
  3. // - Solution: Backend.slnx (.NET 10)
  4. // - Components: Web.Api (IIS antooza-api), Admin (IIS antooza-admin), MailWorker (WinSW antooza-mail)
  5. // - Agent: CTL-Window-Server (Jenkins inbound agent on 10.10.0.100)
  6. // - Branches: main / dev -> deploy to dev environment (antooza.com — dev 서버를 운영 도메인으로)
  7. // others -> build/test only
  8. // - Selective: AUTO_DETECT (default) via git diff, or manual checkboxes
  9. // =====================================================================
  10. pipeline {
  11. agent { label 'CTL-Window-Server' }
  12. options {
  13. timestamps()
  14. disableConcurrentBuilds()
  15. buildDiscarder(logRotator(numToKeepStr: '20'))
  16. }
  17. parameters {
  18. booleanParam(name: 'AUTO_DETECT', defaultValue: true,
  19. description: 'true: git diff로 변경된 컴포넌트만 자동 배포 (아래 체크박스 무시)')
  20. booleanParam(name: 'DEPLOY_API', defaultValue: true)
  21. booleanParam(name: 'DEPLOY_ADMIN', defaultValue: true)
  22. booleanParam(name: 'DEPLOY_MAIL', defaultValue: true)
  23. }
  24. environment {
  25. DOTNET_CLI_TELEMETRY_OPTOUT = '1'
  26. DOTNET_NOLOGO = '1'
  27. JAVA_TOOL_OPTIONS = '-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8'
  28. // Deployment roots (CTL-Window-Server) — 서버 관례 C:\workspace\IIS\<site>
  29. IIS_ROOT = 'C:\\workspace\\IIS'
  30. IIS_RELEASES = 'C:\\workspace\\IIS\\_releases'
  31. WORKER_ROOT = 'C:\\workspace\\antooza'
  32. WORKER_RELEASES = 'C:\\workspace\\antooza\\_releases'
  33. // Project paths (relative to Backend/ working dir on agent)
  34. SLN_FILE = 'Backend.slnx'
  35. API_PROJ = 'Web.Api\\Web.Api.csproj'
  36. ADMIN_PROJ = 'Admin\\Admin.csproj'
  37. MAIL_PROJ = 'MailWorker\\MailWorker.csproj'
  38. // EF Core
  39. EF_CTX_PROJ = 'Infrastructure'
  40. EF_START_PROJ = 'Admin'
  41. // Change detection paths
  42. SHARED_PATHS = 'Application/,Domain/,Infrastructure/,SharedKernel/,Directory.Build.props,Directory.Packages.props,global.json,Backend.slnx'
  43. API_PATHS = 'Web.Api/'
  44. ADMIN_PATHS = 'Admin/'
  45. MAIL_PATHS = 'MailWorker/'
  46. // Health check (loopback + Host header to hit the right IIS site binding)
  47. // IIS HTTP binding은 api.antooza.com. dev 서버를 운영 도메인으로 사용(dev- 접두사 제거) — Host 헤더가 사이트 바인딩과 일치해야 health 통과.
  48. API_HEALTH_HOST = 'api.antooza.com'
  49. }
  50. stages {
  51. stage('Locate workdir') {
  52. steps {
  53. script {
  54. // Multibranch checkout drops repo root. Solution lives in Backend/.
  55. // dir('') would throw — use '.' as the no-prefix marker instead.
  56. if (fileExists('Backend\\Backend.slnx')) {
  57. env.WORK_SUBDIR = 'Backend'
  58. } else if (fileExists('Backend.slnx')) {
  59. env.WORK_SUBDIR = '.'
  60. } else {
  61. error 'Cannot locate Backend.slnx in checkout.'
  62. }
  63. echo "Working subdirectory = '${env.WORK_SUBDIR}'"
  64. }
  65. }
  66. }
  67. stage('Detect changes') {
  68. steps {
  69. script {
  70. def doApi = params.DEPLOY_API
  71. def doAdmin = params.DEPLOY_ADMIN
  72. def doMail = params.DEPLOY_MAIL
  73. if (params.AUTO_DETECT) {
  74. def lastSha = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT
  75. if (!lastSha) {
  76. echo 'No previous successful build — deploying all components.'
  77. doApi = doAdmin = doMail = true
  78. } else {
  79. def diffOut = bat(returnStdout: true,
  80. script: "@git diff --name-only ${lastSha} HEAD 2>nul").trim()
  81. if (!diffOut) {
  82. echo "No diff since last successful build (${lastSha}) — nothing to deploy."
  83. doApi = doAdmin = doMail = false
  84. } else {
  85. echo "Changed files since ${lastSha}:\n${diffOut}"
  86. def files = diffOut.split('\r?\n').collect { it.replace('\\', '/') }
  87. if (env.WORK_SUBDIR == 'Backend') {
  88. files = files.findAll { it.startsWith('Backend/') }
  89. .collect { it.substring('Backend/'.length()) }
  90. }
  91. def matchAny = { String csv ->
  92. def patterns = csv.split(',').collect { it.trim() }
  93. files.any { f -> patterns.any { p -> f.startsWith(p) || f == p } }
  94. }
  95. def sharedChanged = matchAny(env.SHARED_PATHS)
  96. doApi = sharedChanged || matchAny(env.API_PATHS)
  97. doAdmin = sharedChanged || matchAny(env.ADMIN_PATHS)
  98. doMail = sharedChanged || matchAny(env.MAIL_PATHS)
  99. }
  100. }
  101. }
  102. env.DO_API = doApi.toString()
  103. env.DO_ADMIN = doAdmin.toString()
  104. env.DO_MAIL = doMail.toString()
  105. echo "Deploy plan -> API:${doApi} ADMIN:${doAdmin} MAIL:${doMail}"
  106. if (!doApi && !doAdmin && !doMail) {
  107. currentBuild.result = 'NOT_BUILT'
  108. error('No components to deploy.')
  109. }
  110. }
  111. }
  112. }
  113. stage('Clean') {
  114. steps {
  115. dir(env.WORK_SUBDIR) {
  116. bat 'if exist publish rmdir /s /q publish'
  117. }
  118. }
  119. }
  120. stage('Restore') {
  121. steps {
  122. dir(env.WORK_SUBDIR) {
  123. bat 'chcp 65001 >NUL && dotnet restore %SLN_FILE%'
  124. }
  125. }
  126. }
  127. stage('Build') {
  128. steps {
  129. dir(env.WORK_SUBDIR) {
  130. bat 'chcp 65001 >NUL && dotnet build %SLN_FILE% -c Release --no-restore'
  131. }
  132. }
  133. }
  134. stage('Publish') {
  135. parallel {
  136. stage('api') {
  137. when { expression { env.DO_API == 'true' } }
  138. steps {
  139. dir(env.WORK_SUBDIR) {
  140. bat 'chcp 65001 >NUL && dotnet publish %API_PROJ% -c Release -o publish\\api --no-build'
  141. }
  142. }
  143. }
  144. stage('admin') {
  145. when { expression { env.DO_ADMIN == 'true' } }
  146. steps {
  147. dir(env.WORK_SUBDIR) {
  148. bat 'chcp 65001 >NUL && dotnet publish %ADMIN_PROJ% -c Release -o publish\\admin --no-build'
  149. }
  150. }
  151. }
  152. stage('mail') {
  153. when { expression { env.DO_MAIL == 'true' } }
  154. steps {
  155. dir(env.WORK_SUBDIR) {
  156. bat 'chcp 65001 >NUL && dotnet publish %MAIL_PROJ% -c Release -o publish\\mail --no-build'
  157. }
  158. }
  159. }
  160. }
  161. }
  162. stage('DB Migration') {
  163. when {
  164. expression { (!env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev']) && (env.DO_API == 'true' || env.DO_ADMIN == 'true') }
  165. }
  166. steps {
  167. dir(env.WORK_SUBDIR) {
  168. withCredentials([string(credentialsId: 'antooza-db-connection-dev', variable: 'CONN')]) {
  169. bat 'chcp 65001 >NUL && dotnet tool restore --tool-manifest dotnet-tools.json'
  170. withEnv([
  171. "ConnectionStrings__DefaultConnection=${CONN}",
  172. "ConnectionStrings__IdentityDbContextConnection=${CONN}"
  173. ]) {
  174. // EF Core CLI: `-c` is short for `--context`, NOT `--configuration`. Use `--configuration Release` explicitly.
  175. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context AppDbContext --configuration Release --no-build"
  176. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context IdentityDbContext --configuration Release --no-build"
  177. }
  178. }
  179. }
  180. }
  181. }
  182. stage('Inject Config') {
  183. when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
  184. steps {
  185. dir(env.WORK_SUBDIR) {
  186. script {
  187. if (env.DO_API == 'true') {
  188. withCredentials([file(credentialsId: 'DEV-ANTOOZA-API', variable: 'CFG')]) {
  189. bat 'copy /Y "%CFG%" publish\\api\\appsettings.Production.json'
  190. }
  191. }
  192. if (env.DO_ADMIN == 'true') {
  193. withCredentials([file(credentialsId: 'DEV-ANTOOZA-ADMIN', variable: 'CFG')]) {
  194. bat 'copy /Y "%CFG%" publish\\admin\\appsettings.Production.json'
  195. }
  196. }
  197. if (env.DO_MAIL == 'true') {
  198. withCredentials([file(credentialsId: 'DEV-ANTOOZA-MAIL', variable: 'CFG')]) {
  199. bat 'copy /Y "%CFG%" publish\\mail\\appsettings.Production.json'
  200. }
  201. }
  202. }
  203. }
  204. }
  205. }
  206. stage('Deploy') {
  207. when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
  208. parallel {
  209. stage('api') {
  210. when { expression { env.DO_API == 'true' } }
  211. steps {
  212. dir(env.WORK_SUBDIR) {
  213. script { deployIIS('antooza-api', 'antooza-api', 'api', 'antooza-api', env.API_HEALTH_HOST, true) }
  214. }
  215. }
  216. }
  217. stage('admin') {
  218. when { expression { env.DO_ADMIN == 'true' } }
  219. steps {
  220. dir(env.WORK_SUBDIR) {
  221. // Admin은 /health 엔드포인트가 없어서 AppPool Started 만 검증
  222. script { deployIIS('antooza-admin', 'antooza-admin', 'admin', 'antooza-admin', null, false) }
  223. }
  224. }
  225. }
  226. stage('mail') {
  227. when { expression { env.DO_MAIL == 'true' } }
  228. steps {
  229. dir(env.WORK_SUBDIR) {
  230. script { deployWinSWWorker('antooza-mail', 'mail') }
  231. }
  232. }
  233. }
  234. }
  235. }
  236. }
  237. post {
  238. success { echo "Build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" }
  239. failure { echo 'Build failed - check stage logs' }
  240. }
  241. }
  242. // ==== Helpers =================================================================
  243. // IIS In-Process 배포.
  244. // siteName : Jenkins 로그용 라벨 (예: antooza-api)
  245. // appPoolName : IIS AppPool 이름 (예: antooza-api) — Stop-WebAppPool / Start-WebAppPool 대상
  246. // srcSubdir : publish 하위 디렉토리 (예: api, admin) — `publish\<srcSubdir>` 에서 복사
  247. // destFolder : C:\workspace\IIS\<destFolder> (antooza-api, antooza-admin)
  248. // healthHost : Host 헤더로 보낼 도메인 (null이면 health 생략)
  249. // checkHealth : true면 /health 200 확인, false면 AppPool Started 만 확인
  250. def deployIIS(siteName, appPoolName, srcSubdir, destFolder, healthHost, checkHealth) {
  251. powershell """
  252. \$ErrorActionPreference = 'Stop'
  253. Import-Module WebAdministration
  254. \$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
  255. \$dest = Join-Path '${env.IIS_ROOT}' '${destFolder}'
  256. \$backup = Join-Path '${env.IIS_RELEASES}' ('${siteName}-' + \$ts)
  257. if (-not (Test-Path '${env.IIS_RELEASES}')) { New-Item -ItemType Directory -Path '${env.IIS_RELEASES}' -Force | Out-Null }
  258. Write-Host "[${siteName}] Stopping AppPool '${appPoolName}'..."
  259. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -eq 'Started') {
  260. Stop-WebAppPool -Name '${appPoolName}'
  261. \$t = 30
  262. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped' -and \$t -gt 0) {
  263. Start-Sleep -Seconds 1; \$t--
  264. }
  265. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped') {
  266. throw '[${siteName}] AppPool did not stop within 30s'
  267. }
  268. }
  269. Start-Sleep -Seconds 2
  270. if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
  271. Write-Host "[${siteName}] Backing up current to \$backup"
  272. New-Item -ItemType Directory -Path \$backup -Force | Out-Null
  273. Move-Item (Join-Path \$dest '*') \$backup -Force
  274. } elseif (-not (Test-Path \$dest)) {
  275. New-Item -ItemType Directory -Path \$dest -Force | Out-Null
  276. }
  277. Write-Host "[${siteName}] Copying new artifacts..."
  278. Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force
  279. Write-Host "[${siteName}] Starting AppPool..."
  280. Start-WebAppPool -Name '${appPoolName}'
  281. \$t = 15
  282. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Started' -and \$t -gt 0) {
  283. Start-Sleep -Seconds 1; \$t--
  284. }
  285. \$ok = \$false
  286. if ('${checkHealth}' -eq 'true') {
  287. 1..10 | ForEach-Object {
  288. Start-Sleep -Seconds 3
  289. try {
  290. \$r = Invoke-WebRequest -Uri 'http://127.0.0.1/health' -Headers @{ Host = '${healthHost}' } -UseBasicParsing -TimeoutSec 5
  291. if (\$r.StatusCode -eq 200) {
  292. Write-Host "[${siteName}] Health OK on attempt \$_"
  293. \$ok = \$true; return
  294. }
  295. } catch {
  296. Write-Host "[${siteName}] Health attempt \$_ failed: \$(\$_.Exception.Message)"
  297. }
  298. }
  299. } else {
  300. 1..3 | ForEach-Object {
  301. Start-Sleep -Seconds 5
  302. \$state = (Get-WebAppPoolState -Name '${appPoolName}').Value
  303. Write-Host "[${siteName}] AppPool state: \$state"
  304. if (\$state -ne 'Started') { \$ok = \$false; return }
  305. \$ok = \$true
  306. }
  307. }
  308. if (-not \$ok) {
  309. Write-Warning "[${siteName}] Verification failed - rolling back"
  310. Stop-WebAppPool -Name '${appPoolName}' -ErrorAction SilentlyContinue
  311. Start-Sleep -Seconds 2
  312. if (Test-Path \$backup) {
  313. Get-ChildItem \$dest -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
  314. Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
  315. Start-WebAppPool -Name '${appPoolName}'
  316. }
  317. throw '[${siteName}] deployment failed; rolled back.'
  318. }
  319. # 백업 5개만 보존
  320. Get-ChildItem '${env.IIS_RELEASES}' -Directory -ErrorAction SilentlyContinue |
  321. Where-Object { \$_.Name -like '${siteName}-*' } |
  322. Sort-Object LastWriteTime -Descending |
  323. Select-Object -Skip 5 |
  324. Remove-Item -Recurse -Force
  325. """
  326. }
  327. // WinSW Worker 배포 (HTTP 없음, 프로세스 살아있는지 확인)
  328. def deployWinSWWorker(svcName, srcSubdir) {
  329. powershell """
  330. \$ErrorActionPreference = 'Stop'
  331. \$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
  332. \$dest = Join-Path '${env.WORKER_ROOT}' '${srcSubdir}'
  333. \$backup = Join-Path '${env.WORKER_RELEASES}' ('${svcName}-' + \$ts)
  334. if (-not (Test-Path '${env.WORKER_RELEASES}')) { New-Item -ItemType Directory -Path '${env.WORKER_RELEASES}' -Force | Out-Null }
  335. Write-Host "[${svcName}] Stopping service..."
  336. Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue
  337. Start-Sleep -Seconds 2
  338. \$keep = @('${svcName}.exe', '${svcName}.xml',
  339. '${svcName}.err.log', '${svcName}.out.log', '${svcName}.wrapper.log')
  340. if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
  341. Write-Host "[${svcName}] Backing up current to \$backup"
  342. New-Item -ItemType Directory -Path \$backup -Force | Out-Null
  343. Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
  344. Move-Item -Destination \$backup -Force
  345. } elseif (-not (Test-Path \$dest)) {
  346. New-Item -ItemType Directory -Path \$dest -Force | Out-Null
  347. }
  348. Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force
  349. # First-deploy auto-install: register WinSW service if not yet present
  350. if (-not (Get-Service ${svcName} -ErrorAction SilentlyContinue)) {
  351. Write-Host "[${svcName}] Service not registered — running '${svcName}.exe install'"
  352. \$installer = Join-Path \$dest '${svcName}.exe'
  353. if (-not (Test-Path \$installer)) {
  354. throw "[${svcName}] WinSW binary not found at \$installer (expected alongside ${svcName}.xml)"
  355. }
  356. & \$installer install
  357. if (\$LASTEXITCODE -ne 0) { throw "[${svcName}] install failed with exit code \$LASTEXITCODE" }
  358. Start-Sleep -Seconds 2
  359. }
  360. Start-Service ${svcName}
  361. \$ok = \$true
  362. 1..3 | ForEach-Object {
  363. Start-Sleep -Seconds 5
  364. \$status = (Get-Service ${svcName}).Status
  365. Write-Host "[${svcName}] Status: \$status"
  366. if (\$status -ne 'Running') { \$ok = \$false; return }
  367. }
  368. if (-not \$ok) {
  369. Write-Warning "[${svcName}] Worker did not stay Running - rolling back"
  370. Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue
  371. if (Test-Path \$backup) {
  372. Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
  373. Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
  374. Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
  375. Start-Service ${svcName}
  376. }
  377. throw "${svcName} failed to start; rolled back."
  378. }
  379. Get-ChildItem '${env.WORKER_RELEASES}' -Directory -ErrorAction SilentlyContinue |
  380. Where-Object { \$_.Name -like '${svcName}-*' } |
  381. Sort-Object LastWriteTime -Descending |
  382. Select-Object -Skip 5 |
  383. Remove-Item -Recurse -Force
  384. """
  385. }