Jenkinsfile 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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 (dev-*.antooza.com)
  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, F: drive)
  29. IIS_ROOT = 'F:\\IIS\\ANTOOZA'
  30. IIS_RELEASES = 'F:\\IIS\\ANTOOZA\\_releases'
  31. WORKER_ROOT = 'F:\\workspace\\ANTOOZA'
  32. WORKER_RELEASES = 'F:\\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은 dev-api.antooza.com (HTTPS는 .dev). 내부 health check는 HTTP라 .live 사용.
  48. API_HEALTH_HOST = 'dev-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. allOf {
  165. anyOf { branch 'main'; branch 'dev' }
  166. expression { env.DO_API == 'true' || env.DO_ADMIN == 'true' }
  167. }
  168. }
  169. steps {
  170. dir(env.WORK_SUBDIR) {
  171. withCredentials([string(credentialsId: 'antooza-db-connection-dev', variable: 'CONN')]) {
  172. bat 'chcp 65001 >NUL && dotnet tool restore --tool-manifest dotnet-tools.json'
  173. withEnv([
  174. "ConnectionStrings__DefaultConnection=${CONN}",
  175. "ConnectionStrings__IdentityDbContextConnection=${CONN}"
  176. ]) {
  177. // EF Core CLI: `-c` is short for `--context`, NOT `--configuration`. Use `--configuration Release` explicitly.
  178. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context AppDbContext --configuration Release --no-build"
  179. bat "chcp 65001 >NUL && dotnet ef database update --project ${EF_CTX_PROJ} --startup-project ${EF_START_PROJ} --context IdentityDbContext --configuration Release --no-build"
  180. }
  181. }
  182. }
  183. }
  184. }
  185. stage('Inject Config') {
  186. when { anyOf { branch 'main'; branch 'dev' } }
  187. steps {
  188. dir(env.WORK_SUBDIR) {
  189. script {
  190. if (env.DO_API == 'true') {
  191. withCredentials([file(credentialsId: 'DEV-ANTOOZA-API', variable: 'CFG')]) {
  192. bat 'copy /Y "%CFG%" publish\\api\\appsettings.Production.json'
  193. }
  194. }
  195. if (env.DO_ADMIN == 'true') {
  196. withCredentials([file(credentialsId: 'DEV-ANTOOZA-ADMIN', variable: 'CFG')]) {
  197. bat 'copy /Y "%CFG%" publish\\admin\\appsettings.Production.json'
  198. }
  199. }
  200. if (env.DO_MAIL == 'true') {
  201. withCredentials([file(credentialsId: 'DEV-ANTOOZA-MAIL', variable: 'CFG')]) {
  202. bat 'copy /Y "%CFG%" publish\\mail\\appsettings.Production.json'
  203. }
  204. }
  205. }
  206. }
  207. }
  208. }
  209. stage('Deploy') {
  210. when { anyOf { branch 'main'; branch 'dev' } }
  211. parallel {
  212. stage('api') {
  213. when { expression { env.DO_API == 'true' } }
  214. steps {
  215. dir(env.WORK_SUBDIR) {
  216. script { deployIIS('ANTOOZA-API', 'ANTOOZA-API', 'api', 'API', env.API_HEALTH_HOST, true) }
  217. }
  218. }
  219. }
  220. stage('admin') {
  221. when { expression { env.DO_ADMIN == 'true' } }
  222. steps {
  223. dir(env.WORK_SUBDIR) {
  224. // Admin은 /health 엔드포인트가 없어서 AppPool Started 만 검증
  225. script { deployIIS('ANTOOZA-Admin', 'ANTOOZA-Admin', 'admin', 'Admin', null, false) }
  226. }
  227. }
  228. }
  229. stage('mail') {
  230. when { expression { env.DO_MAIL == 'true' } }
  231. steps {
  232. dir(env.WORK_SUBDIR) {
  233. script { deployWinSWWorker('antooza-mail', 'mail') }
  234. }
  235. }
  236. }
  237. }
  238. }
  239. }
  240. post {
  241. success { echo "Build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" }
  242. failure { echo 'Build failed - check stage logs' }
  243. }
  244. }
  245. // ==== Helpers =================================================================
  246. // IIS In-Process 배포.
  247. // siteName : Jenkins 로그용 라벨 (예: ANTOOZA-API)
  248. // appPoolName : IIS AppPool 이름 (예: ANTOOZA-API) — Stop-WebAppPool / Start-WebAppPool 대상
  249. // srcSubdir : publish 하위 디렉토리 (예: api, admin) — `publish\<srcSubdir>` 에서 복사
  250. // destFolder : F:\IIS\ANTOOZA\<destFolder> (대소문자: API, Admin)
  251. // healthHost : Host 헤더로 보낼 도메인 (null이면 health 생략)
  252. // checkHealth : true면 /health 200 확인, false면 AppPool Started 만 확인
  253. def deployIIS(siteName, appPoolName, srcSubdir, destFolder, healthHost, checkHealth) {
  254. powershell """
  255. \$ErrorActionPreference = 'Stop'
  256. Import-Module WebAdministration
  257. \$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
  258. \$dest = Join-Path '${env.IIS_ROOT}' '${destFolder}'
  259. \$backup = Join-Path '${env.IIS_RELEASES}' ('${siteName}-' + \$ts)
  260. if (-not (Test-Path '${env.IIS_RELEASES}')) { New-Item -ItemType Directory -Path '${env.IIS_RELEASES}' -Force | Out-Null }
  261. Write-Host "[${siteName}] Stopping AppPool '${appPoolName}'..."
  262. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -eq 'Started') {
  263. Stop-WebAppPool -Name '${appPoolName}'
  264. \$t = 30
  265. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped' -and \$t -gt 0) {
  266. Start-Sleep -Seconds 1; \$t--
  267. }
  268. if ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Stopped') {
  269. throw '[${siteName}] AppPool did not stop within 30s'
  270. }
  271. }
  272. Start-Sleep -Seconds 2
  273. if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
  274. Write-Host "[${siteName}] Backing up current to \$backup"
  275. New-Item -ItemType Directory -Path \$backup -Force | Out-Null
  276. Move-Item (Join-Path \$dest '*') \$backup -Force
  277. } elseif (-not (Test-Path \$dest)) {
  278. New-Item -ItemType Directory -Path \$dest -Force | Out-Null
  279. }
  280. Write-Host "[${siteName}] Copying new artifacts..."
  281. Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force
  282. Write-Host "[${siteName}] Starting AppPool..."
  283. Start-WebAppPool -Name '${appPoolName}'
  284. \$t = 15
  285. while ((Get-WebAppPoolState -Name '${appPoolName}').Value -ne 'Started' -and \$t -gt 0) {
  286. Start-Sleep -Seconds 1; \$t--
  287. }
  288. \$ok = \$false
  289. if ('${checkHealth}' -eq 'true') {
  290. 1..10 | ForEach-Object {
  291. Start-Sleep -Seconds 3
  292. try {
  293. \$r = Invoke-WebRequest -Uri 'http://127.0.0.1/health' -Headers @{ Host = '${healthHost}' } -UseBasicParsing -TimeoutSec 5
  294. if (\$r.StatusCode -eq 200) {
  295. Write-Host "[${siteName}] Health OK on attempt \$_"
  296. \$ok = \$true; return
  297. }
  298. } catch {
  299. Write-Host "[${siteName}] Health attempt \$_ failed: \$(\$_.Exception.Message)"
  300. }
  301. }
  302. } else {
  303. 1..3 | ForEach-Object {
  304. Start-Sleep -Seconds 5
  305. \$state = (Get-WebAppPoolState -Name '${appPoolName}').Value
  306. Write-Host "[${siteName}] AppPool state: \$state"
  307. if (\$state -ne 'Started') { \$ok = \$false; return }
  308. \$ok = \$true
  309. }
  310. }
  311. if (-not \$ok) {
  312. Write-Warning "[${siteName}] Verification failed - rolling back"
  313. Stop-WebAppPool -Name '${appPoolName}' -ErrorAction SilentlyContinue
  314. Start-Sleep -Seconds 2
  315. if (Test-Path \$backup) {
  316. Get-ChildItem \$dest -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
  317. Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
  318. Start-WebAppPool -Name '${appPoolName}'
  319. }
  320. throw '[${siteName}] deployment failed; rolled back.'
  321. }
  322. # 백업 5개만 보존
  323. Get-ChildItem '${env.IIS_RELEASES}' -Directory -ErrorAction SilentlyContinue |
  324. Where-Object { \$_.Name -like '${siteName}-*' } |
  325. Sort-Object LastWriteTime -Descending |
  326. Select-Object -Skip 5 |
  327. Remove-Item -Recurse -Force
  328. """
  329. }
  330. // WinSW Worker 배포 (HTTP 없음, 프로세스 살아있는지 확인)
  331. def deployWinSWWorker(svcName, srcSubdir) {
  332. powershell """
  333. \$ErrorActionPreference = 'Stop'
  334. \$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
  335. \$dest = Join-Path '${env.WORKER_ROOT}' '${srcSubdir}'
  336. \$backup = Join-Path '${env.WORKER_RELEASES}' ('${svcName}-' + \$ts)
  337. if (-not (Test-Path '${env.WORKER_RELEASES}')) { New-Item -ItemType Directory -Path '${env.WORKER_RELEASES}' -Force | Out-Null }
  338. Write-Host "[${svcName}] Stopping service..."
  339. Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue
  340. Start-Sleep -Seconds 2
  341. \$keep = @('${svcName}.exe', '${svcName}.xml',
  342. '${svcName}.err.log', '${svcName}.out.log', '${svcName}.wrapper.log')
  343. if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
  344. Write-Host "[${svcName}] Backing up current to \$backup"
  345. New-Item -ItemType Directory -Path \$backup -Force | Out-Null
  346. Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
  347. Move-Item -Destination \$backup -Force
  348. } elseif (-not (Test-Path \$dest)) {
  349. New-Item -ItemType Directory -Path \$dest -Force | Out-Null
  350. }
  351. Copy-Item ("publish\\${srcSubdir}\\*") \$dest -Recurse -Force
  352. # First-deploy auto-install: register WinSW service if not yet present
  353. if (-not (Get-Service ${svcName} -ErrorAction SilentlyContinue)) {
  354. Write-Host "[${svcName}] Service not registered — running '${svcName}.exe install'"
  355. \$installer = Join-Path \$dest '${svcName}.exe'
  356. if (-not (Test-Path \$installer)) {
  357. throw "[${svcName}] WinSW binary not found at \$installer (expected alongside ${svcName}.xml)"
  358. }
  359. & \$installer install
  360. if (\$LASTEXITCODE -ne 0) { throw "[${svcName}] install failed with exit code \$LASTEXITCODE" }
  361. Start-Sleep -Seconds 2
  362. }
  363. Start-Service ${svcName}
  364. \$ok = \$true
  365. 1..3 | ForEach-Object {
  366. Start-Sleep -Seconds 5
  367. \$status = (Get-Service ${svcName}).Status
  368. Write-Host "[${svcName}] Status: \$status"
  369. if (\$status -ne 'Running') { \$ok = \$false; return }
  370. }
  371. if (-not \$ok) {
  372. Write-Warning "[${svcName}] Worker did not stay Running - rolling back"
  373. Stop-Service ${svcName} -Force -ErrorAction SilentlyContinue
  374. if (Test-Path \$backup) {
  375. Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
  376. Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
  377. Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
  378. Start-Service ${svcName}
  379. }
  380. throw "${svcName} failed to start; rolled back."
  381. }
  382. Get-ChildItem '${env.WORKER_RELEASES}' -Directory -ErrorAction SilentlyContinue |
  383. Where-Object { \$_.Name -like '${svcName}-*' } |
  384. Sort-Object LastWriteTime -Descending |
  385. Select-Object -Skip 5 |
  386. Remove-Item -Recurse -Force
  387. """
  388. }