ソースを参照

ci: frontend deploy to antooza env (CTL/.201) - WinSW bitforum-frontend :3001 + IIS reverse-proxy + DEV-BITFORUM-FRONTEND-ENV

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jino 1 日 前
コミット
93377e0f15
3 ファイル変更262 行追加53 行削除
  1. 206 53
      Jenkinsfile
  2. 35 0
      bitforum-frontend.xml
  3. 21 0
      web.config

+ 206 - 53
Jenkinsfile

@@ -1,60 +1,213 @@
+// =====================================================================
+// BITFORUM Frontend Jenkinsfile (Next.js 15 standalone + WinSW + IIS reverse proxy)
+//   - Branches:        main / dev  -> deploy
+//                      others      -> build/test only
+//   - Agent:           CTL-Window-Server (Node v24)
+//   - Deploy path:     C:\workspace\IIS\bitforum-front (?쒕쾭 愿€濡€)
+//   - Process:         WinSW service 'bitforum-frontend' (node server.js on :3001)
+//   - IIS:             bitforum-front site (URL Rewrite) reverse-proxies :80 -> 127.0.0.1:3001
+// =====================================================================
+
 pipeline {
-    agent { label 'built-in' }
-    environment {
-        DEPLOY_FRONTEND = '/mnt/h/bitforum/sources'
-    }
-    stages {
-        stage('Install') {
-            steps {
-                script {
-                    // package-lock.json 해시 비교해서 변경됐을 때만 재설치
-                    def lockHash = sh(
-                        script: 'sha256sum package-lock.json | cut -d" " -f1',
-                        returnStdout: true
-                    ).trim()
-                    def cacheFile = '.npm-lock-hash'
-                    def prevHash = fileExists(cacheFile)
-                        ? readFile(cacheFile).trim()
-                        : ''
-
-                    if (lockHash != prevHash) {
-                        echo "package-lock.json 변경 감지 → npm ci 실행"
-                        sh 'npm ci'
-                        writeFile file: cacheFile, text: lockHash
-                    } else {
-                        echo "package-lock.json 변경 없음 → 설치 스킵"
-                    }
-                }
-            }
+  agent { label 'CTL-Window-Server' }
+
+  options {
+    timestamps()
+    disableConcurrentBuilds()
+    buildDiscarder(logRotator(numToKeepStr: '20'))
+  }
+
+  environment {
+    DEPLOY_ROOT = 'C:\\workspace\\IIS\\bitforum-front'
+    RELEASES    = 'C:\\workspace\\IIS\\_releases'
+    SVC_NAME    = 'bitforum-frontend'
+  }
+
+  stages {
+
+    stage('Install') {
+      steps {
+        bat 'chcp 65001 >NUL && node -v && npm -v'
+        script {
+          // package-lock.json 蹂€寃?+ node_modules 議댁옱 ?щ? 議고빀?쇰줈 npm ci ?ㅽ궢.
+          // 泥?鍮뚮뱶 ?먮뒗 lock 蹂€寃??쒖뿉留?full install (罹먯떆 ?쒖슜 ?꾪빐 --prefer-offline).
+          def lockChanged = bat(returnStatus: true,
+              script: '@git diff --quiet HEAD~1 HEAD -- package-lock.json 2>nul') != 0
+          def hasModules  = fileExists('node_modules/.package-lock.json')
+          if (!hasModules || lockChanged) {
+            echo "Installing (lockChanged=${lockChanged}, hasModules=${hasModules})"
+            bat 'chcp 65001 >NUL && npm ci --no-audit --no-fund --prefer-offline'
+          } else {
+            echo 'package-lock.json unchanged + node_modules present ??skip npm ci'
+          }
         }
-        stage('Build') {
-            steps {
-                sh 'npm run build'
-            }
+      }
+    }
+
+    stage('Inject Env (Secret File)') {
+      when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
+      steps {
+        script {
+          // Backend ?⑦꽩怨??숈씪: 釉뚮옖移섎퀎濡?Jenkins Secret File 二쇱엯
+          //   dev  ??DEV-BITFORUM-FRONTEND-ENV  (api.bitforum.io ??dev ?쒕쾭 ?댁쁺 ?꾨찓??
+          //   main ??PROD-BITFORUM-FRONTEND-ENV (api.bitforum.io)
+          // .env.production ?€ .gitignore ??(Backend ??appsettings.Production.json 泥섎읆 鍮뚮뱶 ?쒖젏?먮쭔 議댁옱).
+          def credId = (env.BRANCH_NAME == 'main') ? 'PROD-BITFORUM-FRONTEND-ENV' : 'DEV-BITFORUM-FRONTEND-ENV'
+          echo "[env] Using Jenkins Secret File: ${credId}"
+          withCredentials([file(credentialsId: credId, variable: 'CFG')]) {
+            bat 'chcp 65001 >NUL && copy /Y "%CFG%" .env.production >NUL && for %%I in (.env.production) do @echo [env] .env.production injected (%%~zI bytes)'
+          }
         }
-        stage('Deploy') {
-            steps {
-                sh '''
-                    rsync -a --stats --delete \
-                        --exclude="web.config" \
-                        --exclude="ecosystem.config.js" \
-                        --exclude="restart.ps1" \
-                        --exclude=".env*" \
-                        .next/standalone/ ${DEPLOY_FRONTEND}/
-                '''
-                sh 'rsync -a --stats --delete .next/static/ ${DEPLOY_FRONTEND}/.next/static/'
-                sh 'rsync -a --stats --delete public/ ${DEPLOY_FRONTEND}/public/'
-            }
+      }
+    }
+
+    stage('Build') {
+      steps {
+        bat 'chcp 65001 >NUL && npm run build'
+      }
+    }
+
+    stage('Deploy') {
+      when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } }
+      steps {
+        script { deployFrontend() }
+      }
+    }
+  }
+
+  post {
+    success { echo "Frontend build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" }
+    failure { echo 'Frontend build/deploy failed - check stage logs' }
+  }
+}
+
+def deployFrontend() {
+  powershell """
+    \$ErrorActionPreference = 'Stop'
+
+    \$ts     = Get-Date -Format 'yyyyMMdd-HHmmss'
+    \$dest   = '${env.DEPLOY_ROOT}'
+    \$backup = Join-Path '${env.RELEASES}' ('${env.SVC_NAME}-' + \$ts)
+
+    if (-not (Test-Path '${env.RELEASES}')) { New-Item -ItemType Directory -Path '${env.RELEASES}' -Force | Out-Null }
+
+    Write-Host '[${env.SVC_NAME}] Stopping service...'
+    Stop-Service ${env.SVC_NAME} -Force -ErrorAction SilentlyContinue
+    Start-Sleep -Seconds 2
+
+    # Preserve infrastructure files (WinSW + web.config + logs)
+    \$keep = @('${env.SVC_NAME}.exe', '${env.SVC_NAME}.xml', 'web.config',
+              '${env.SVC_NAME}.err.log', '${env.SVC_NAME}.out.log', '${env.SVC_NAME}.wrapper.log')
+
+    if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) {
+      Write-Host ('[${env.SVC_NAME}] Backing up current to ' + \$backup)
+      New-Item -ItemType Directory -Path \$backup -Force | Out-Null
+      Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
+        Move-Item -Destination \$backup -Force
+    } elseif (-not (Test-Path \$dest)) {
+      New-Item -ItemType Directory -Path \$dest -Force | Out-Null
+    }
+
+    # Next.js standalone layout:
+    #   .next/standalone/  -> server.js + minimal node_modules + .next folder (server bundles)
+    #   .next/static/      -> static assets (must be copied to <dest>/.next/static)
+    #   public/            -> public assets (must be copied to <dest>/public)
+    Write-Host '[${env.SVC_NAME}] Copying standalone server...'
+    Copy-Item '.next\\standalone\\*' \$dest -Recurse -Force
+
+    Write-Host '[${env.SVC_NAME}] Copying static assets...'
+    New-Item -ItemType Directory -Path (Join-Path \$dest '.next\\static') -Force | Out-Null
+    Copy-Item '.next\\static\\*' (Join-Path \$dest '.next\\static') -Recurse -Force
+
+    Write-Host '[${env.SVC_NAME}] Copying public assets...'
+    New-Item -ItemType Directory -Path (Join-Path \$dest 'public') -Force | Out-Null
+    if (Test-Path 'public') {
+      Copy-Item 'public\\*' (Join-Path \$dest 'public') -Recurse -Force
+    }
+
+    # Server-side env (.env.production) ??Next.js standalone runtime reads from cwd.
+    # NEXT_PUBLIC_* ?€ 鍮뚮뱶 ??inline ?섏?留? server-side (API_URL, SIGNALR_CHAT_URL ?? ???고???濡쒕뱶.
+    if (Test-Path '.env.production') {
+      Write-Host '[${env.SVC_NAME}] Copying .env.production...'
+      Copy-Item '.env.production' (Join-Path \$dest '.env.production') -Force
+    } else {
+      Write-Warning '[${env.SVC_NAME}] .env.production missing in workspace - server-side env will fall back to OS/WinSW env'
+    }
+
+    # Server-side env (.env.production) ??Next.js standalone runtime reads from cwd
+    # NEXT_PUBLIC_* ?€ 鍮뚮뱶 ??inline ?섏?留? server-side (API_URL, SIGNALR_CHAT_URL ?? ???고???濡쒕뱶
+    if (Test-Path '.env.production') {
+      Write-Host '[${env.SVC_NAME}] Copying .env.production...'
+      Copy-Item '.env.production' (Join-Path \$dest '.env.production') -Force
+    } else {
+      Write-Warning '[${env.SVC_NAME}] .env.production missing in workspace - server-side env will fall back to OS/WinSW env'
+    }
+
+    # Infra files (IIS reverse-proxy web.config + WinSW service xml) - carried in repo, placed into dest.
+    Write-Host '[${env.SVC_NAME}] Copying infra files (web.config + WinSW xml)...'
+    if (Test-Path 'web.config') { Copy-Item 'web.config' (Join-Path \$dest 'web.config') -Force }
+    if (Test-Path '${env.SVC_NAME}.xml') { Copy-Item '${env.SVC_NAME}.xml' (Join-Path \$dest '${env.SVC_NAME}.xml') -Force }
+    # Auto-provision WinSW binary from antooza-front (same WinSW exe, renamed) if missing.
+    \$svcExe = Join-Path \$dest '${env.SVC_NAME}.exe'
+    if (-not (Test-Path \$svcExe)) {
+      \$srcExe = 'C:\\workspace\\IIS\\antooza-front\\antooza-frontend.exe'
+      if (Test-Path \$srcExe) { Write-Host '[${env.SVC_NAME}] Provisioning WinSW binary from antooza-front'; Copy-Item \$srcExe \$svcExe -Force }
+    }
+    # First-deploy auto-install WinSW
+    if (-not (Get-Service ${env.SVC_NAME} -ErrorAction SilentlyContinue)) {
+      Write-Host '[${env.SVC_NAME}] Service not registered - installing'
+      \$installer = Join-Path \$dest '${env.SVC_NAME}.exe'
+      if (-not (Test-Path \$installer)) {
+        throw ('[${env.SVC_NAME}] WinSW binary missing at ' + \$installer + ' ??place ${env.SVC_NAME}.exe (renamed WinSW) + ${env.SVC_NAME}.xml in ' + \$dest)
+      }
+      & \$installer install
+      if (\$LASTEXITCODE -ne 0) { throw ('[${env.SVC_NAME}] install failed (exit ' + \$LASTEXITCODE + ')') }
+      Start-Sleep -Seconds 2
+    }
+
+    Write-Host '[${env.SVC_NAME}] Starting service...'
+    Start-Service ${env.SVC_NAME}
+
+    # Health check: HTTP 200/3xx on http://127.0.0.1:3001/
+    \$ok = \$false
+    1..15 | ForEach-Object {
+      Start-Sleep -Seconds 3
+      try {
+        \$r = Invoke-WebRequest -Uri 'http://127.0.0.1:3001/' -UseBasicParsing -TimeoutSec 5 -MaximumRedirection 0
+        if (\$r.StatusCode -ge 200 -and \$r.StatusCode -lt 400) {
+          Write-Host ('[${env.SVC_NAME}] Healthy (HTTP ' + \$r.StatusCode + ') on attempt ' + \$_)
+          \$ok = \$true; return
         }
-        stage('Restart Frontend') {
-            agent { label 'windows-host' }
-            steps {
-                bat 'chcp 65001 && powershell.exe -ExecutionPolicy Bypass -File H:\\IIS\\bitforum\\restart.ps1'
-            }
+      } catch {
+        \$resp = \$_.Exception.Response
+        if (\$resp) {
+          \$code = [int]\$resp.StatusCode
+          if (\$code -ge 200 -and \$code -lt 400) {
+            Write-Host ('[${env.SVC_NAME}] Healthy (HTTP ' + \$code + ') on attempt ' + \$_)
+            \$ok = \$true; return
+          }
         }
+        Write-Host ('[${env.SVC_NAME}] attempt ' + \$_ + ' failed: ' + \$_.Exception.Message)
+      }
     }
-    post {
-        success { echo 'Frontend deploy success!' }
-        failure { echo 'Frontend deploy failed!' }
+
+    if (-not \$ok) {
+      Write-Warning '[${env.SVC_NAME}] Verification failed - rolling back'
+      Stop-Service ${env.SVC_NAME} -Force -ErrorAction SilentlyContinue
+      if (Test-Path \$backup) {
+        Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } |
+          Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
+        Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force
+        Start-Service ${env.SVC_NAME}
+      }
+      throw '${env.SVC_NAME} deployment failed; rolled back.'
     }
-}
+
+    # Retain only 5 recent backups
+    Get-ChildItem '${env.RELEASES}' -Directory -ErrorAction SilentlyContinue |
+      Where-Object { \$_.Name -like '${env.SVC_NAME}-*' } |
+      Sort-Object LastWriteTime -Descending |
+      Select-Object -Skip 5 |
+      Remove-Item -Recurse -Force
+  """
+}

+ 35 - 0
bitforum-frontend.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  WinSW config for bitForum Frontend (Next.js 15 standalone).
+  Deploy path: C:\workspace\IIS\bitforum-front
+  Port 3001 (antooza-frontend uses 3000). IIS bitforum-front reverse-proxies :80 -> 127.0.0.1:3001.
+  Service binary bitforum-frontend.exe (renamed WinSW) is auto-provisioned from antooza-front by the Jenkinsfile on first deploy.
+-->
+<service>
+  <id>bitforum-frontend</id>
+  <name>bitForum Frontend (Next.js standalone)</name>
+  <description>Next.js production server for bitforum.io (port 3001)</description>
+
+  <executable>node</executable>
+  <arguments>server.js</arguments>
+  <workingdirectory>C:\workspace\IIS\bitforum-front</workingdirectory>
+
+  <env name="NODE_ENV" value="production"/>
+  <env name="PORT" value="3001"/>
+  <env name="HOSTNAME" value="127.0.0.1"/>
+  <env name="NODE_TLS_REJECT_UNAUTHORIZED" value="1"/>
+
+  <log mode="roll-by-size">
+    <sizeThreshold>10240</sizeThreshold>
+    <keepFiles>5</keepFiles>
+  </log>
+
+  <onfailure action="restart" delay="10 sec"/>
+  <onfailure action="restart" delay="30 sec"/>
+  <onfailure action="restart" delay="60 sec"/>
+
+  <resetfailure>1 hour</resetfailure>
+
+  <stoptimeout>30 sec</stoptimeout>
+  <stopparentprocessfirst>true</stopparentprocessfirst>
+</service>

+ 21 - 0
web.config

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  IIS reverse proxy for bitforum-front (Next.js standalone on 127.0.0.1:3001).
+  Requires IIS URL Rewrite + ARR (proxy enabled server-level) — same as antooza-front.
+  Kept across Jenkins deploys via deployFrontend keep[]/copy.
+  NOTE: HTTPS/DNS(Cloudflare) 미설정 상태 — 지금은 http:80 프록시만. 443 붙으면 HTTPS 강제 규칙 추가.
+-->
+<configuration>
+  <system.webServer>
+    <httpErrors existingResponse="PassThrough" errorMode="Detailed" />
+    <rewrite>
+      <rules>
+        <rule name="ReverseProxyToNode" stopProcessing="true">
+          <match url="(.*)" />
+          <action type="Rewrite" url="http://127.0.0.1:3001/{R:1}" />
+        </rule>
+      </rules>
+    </rewrite>
+    <webSocket enabled="true" />
+  </system.webServer>
+</configuration>