Jenkinsfile.prod 15 KB

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