Files
iot-device-management-frontend/Jenkinsfile
lzh 2fa613fd44 build(ci): 新增 CI/CD 配置 + 修复 3 个部署 bug
新增:
- Jenkinsfile:master→PROD(172.17.16.14)、release/next→STAGING(172.17.16.7)
  镜像推 172.17.16.7:5000/aiot-iot-ui,宿主机暴露 90
- docker-compose.frontend.yml:nginx 容器加入 1panel-network
- 根 Dockerfile + apps/web-antd/nginx.conf:与 vben 前端结构对齐
  nginx 反代 /admin-api → http://aiot-gateway:48080

修复:
- .env.production VITE_GLOB_API_URL 硬编码 127.0.0.1 → /admin-api(走 nginx 反代)
- scripts/deploy/Dockerfile 引用不存在的 playground/dist → apps/web-antd/dist
  并改用 turbo 单 filter 构建(与根 Dockerfile 一致)
2026-04-28 14:40:47 +08:00

339 lines
13 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================
// AIoT IoT UI - Jenkins Pipeline
// ============================================
// 双分支部署:
// master -> PROD (172.17.16.14:90)
// release/next -> STAGING (172.17.16.7:90)
// 镜像仓库172.17.16.7:5000
// ============================================
pipeline {
agent any
options {
buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5'))
disableConcurrentBuilds()
timeout(time: 45, unit: 'MINUTES')
timestamps()
retry(1)
}
environment {
// ===== 应用标识 =====
APP_NAME = 'aiot-iot-ui'
CONTAINER_NAME = 'aiot-iot-ui'
HOST_PORT = '90'
// ===== 镜像仓库 =====
REGISTRY = '172.17.16.7:5000'
REGISTRY_HOST = '172.17.16.7'
IMAGE_NAME = "${REGISTRY}/${APP_NAME}"
// ===== 部署目标(默认 PRODrelease/next 在 Initialize 阶段覆盖) =====
DEPLOY_HOST = '172.17.16.14'
DEPLOY_PATH = '/opt/aiot-iot-ui'
STAGING_DEPLOY_HOST = '172.17.16.7'
STAGING_DEPLOY_PATH = '/opt/aiot-iot-ui'
SSH_KEY = '/var/jenkins_home/.ssh/id_rsa'
// ===== 健康检查 =====
HEALTH_CHECK_TIMEOUT = '60'
HEALTH_CHECK_INTERVAL = '5'
// ===== 镜像保留份数 =====
IMAGE_KEEP_COUNT = '3'
// ===== 磁盘守护阈值(% =====
DISK_FREE_MIN_PCT = '5'
DISK_FREE_WARN_PCT = '10'
}
stages {
stage('Initialize') {
steps {
script {
env.PIPELINE_START_TIME = System.currentTimeMillis()
echo "=========================================="
echo " ${env.APP_NAME} - CI/CD Pipeline"
echo "=========================================="
echo "Branch: ${env.BRANCH_NAME}"
echo "Build: #${env.BUILD_NUMBER}"
echo "=========================================="
if (env.BRANCH_NAME == 'release/next') {
env.DEPLOY_HOST = env.STAGING_DEPLOY_HOST
env.DEPLOY_PATH = env.STAGING_DEPLOY_PATH
echo "📦 Deploy target: STAGING (${env.DEPLOY_HOST}:${env.HOST_PORT})"
} else {
echo "📦 Deploy target: PRODUCTION (${env.DEPLOY_HOST}:${env.HOST_PORT})"
}
}
}
}
stage('Checkout') {
steps {
script {
retry(3) { checkout scm }
def shortCommit = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
def sanitized = env.BRANCH_NAME.replaceAll('/', '-')
env.IMAGE_TAG = "${sanitized}-${env.BUILD_NUMBER}-${shortCommit}"
env.PREVIOUS_IMAGE_TAG = getPreviousImageTag()
echo "🏷️ Tag: ${env.IMAGE_TAG}"
echo "🔖 Previous Tag (rollback to): ${env.PREVIOUS_IMAGE_TAG}"
}
}
}
stage('Pre-build Check') {
steps {
script {
sh "docker version >/dev/null 2>&1 || { echo '❌ Docker not available'; exit 1; }"
sh """
curl -f http://${env.REGISTRY}/v2/ >/dev/null 2>&1 || \
echo '⚠️ Registry not accessible, will continue (build still produces local image)'
"""
def diskUsage = sh(
script: "df ${env.WORKSPACE} | tail -1 | awk '{print \$5}' | sed 's/%//'",
returnStdout: true
).trim() as int
if (diskUsage > 80) {
echo "⚠️ Local disk usage ${diskUsage}%, pruning Docker..."
sh "docker system prune -f || true"
}
}
}
}
stage('Build & Push Image') {
steps {
script {
def buildStart = System.currentTimeMillis()
echo "🔨 Building ${env.IMAGE_NAME}:${env.IMAGE_TAG}"
sh """
set -e
docker build \\
--network=1panel-network \\
-t ${env.IMAGE_NAME}:${env.IMAGE_TAG} \\
.
docker push ${env.IMAGE_NAME}:${env.IMAGE_TAG}
if [ "${env.BRANCH_NAME}" = "master" ]; then
docker tag ${env.IMAGE_NAME}:${env.IMAGE_TAG} ${env.IMAGE_NAME}:latest
docker push ${env.IMAGE_NAME}:latest
fi
"""
def imageSize = sh(
script: "docker images ${env.IMAGE_NAME}:${env.IMAGE_TAG} --format '{{.Size}}'",
returnStdout: true
).trim()
def duration = ((System.currentTimeMillis() - buildStart) / 1000) as int
echo "✅ Built and pushed in ${duration}s, size=${imageSize}"
}
}
}
stage('Pre-deploy Check') {
when { anyOf { branch 'master'; branch 'release/next' } }
steps {
script {
echo "🛡️ Pre-deploy: SSH + 磁盘可达性检查"
checkRemoteDiskOrFail(env.DEPLOY_HOST, 'Deploy')
}
}
}
stage('Deploy') {
when { anyOf { branch 'master'; branch 'release/next' } }
steps {
script {
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
echo "📂 同步 docker-compose.frontend.yml 到 ${env.DEPLOY_HOST}:${env.DEPLOY_PATH}"
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} 'mkdir -p ${env.DEPLOY_PATH}'
scp ${sshOpts} docker-compose.frontend.yml root@${env.DEPLOY_HOST}:${env.DEPLOY_PATH}/docker-compose.frontend.yml
"""
try {
echo "🚀 Deploy ${env.APP_NAME}:${env.IMAGE_TAG}"
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
set -e
cd ${env.DEPLOY_PATH}
export IMAGE_TAG=${env.IMAGE_TAG}
export REGISTRY_HOST=${env.REGISTRY}
export HOST_PORT=${env.HOST_PORT}
docker compose -f docker-compose.frontend.yml pull
docker compose -f docker-compose.frontend.yml up -d --force-recreate
sleep 3
'
"""
waitForHealthy(sshOpts)
echo "✅ ${env.APP_NAME} deployed to ${env.DEPLOY_HOST}:${env.HOST_PORT}"
} catch (Exception e) {
echo "❌ Deploy failed: ${e.message}"
if (env.PREVIOUS_IMAGE_TAG && env.PREVIOUS_IMAGE_TAG != 'latest') {
echo "🔄 Rolling back to ${env.PREVIOUS_IMAGE_TAG}"
rollback(sshOpts)
}
throw e
}
}
}
}
stage('Cleanup Old Images') {
when { anyOf { branch 'master'; branch 'release/next' } }
steps {
script {
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
echo "🧹 清理 ${env.DEPLOY_HOST} 上 ${env.APP_NAME} 的旧镜像(保留 ${env.IMAGE_KEEP_COUNT} 份)"
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
docker images ${env.IMAGE_NAME} \\
--format "{{.CreatedAt}}|{{.ID}}|{{.Tag}}" \\
| grep -v "|latest\$" \\
| sort -r \\
| awk -F"|" -v k=${env.IMAGE_KEEP_COUNT} "NR > k {print \\\$2}" \\
| sort -u \\
| xargs -r docker rmi -f 2>/dev/null || true
docker image prune -f --filter "dangling=true" || true
'
"""
}
}
}
}
post {
success {
script {
def total = ((System.currentTimeMillis() - env.PIPELINE_START_TIME.toLong()) / 1000) as int
echo """
==========================================
BUILD SUCCESS
==========================================
App: ${env.APP_NAME}
Tag: ${env.IMAGE_TAG}
Target: ${env.DEPLOY_HOST}:${env.HOST_PORT}
Total: ${total}s
==========================================
"""
}
}
failure {
script {
echo "❌ BUILD FAILED — tag=${env.IMAGE_TAG ?: 'unknown'}, rollback_tag=${env.PREVIOUS_IMAGE_TAG ?: 'N/A'}"
}
}
always {
sh "docker image prune -f --filter 'dangling=true' || true"
}
}
}
// ============================================
// helpers
// ============================================
def getPreviousImageTag() {
try {
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
def tag = sh(
script: """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
docker inspect --format="{{.Config.Image}}" ${env.CONTAINER_NAME} 2>/dev/null \
| sed "s|.*:||" \
|| echo "latest"
' 2>/dev/null || echo "latest"
""",
returnStdout: true
).trim()
return tag ?: 'latest'
} catch (Exception e) {
return 'latest'
}
}
def waitForHealthy(String sshOpts) {
def maxAttempts = (env.HEALTH_CHECK_TIMEOUT.toInteger() / env.HEALTH_CHECK_INTERVAL.toInteger()) as int
echo "⏳ 等待 ${env.APP_NAME} 健康(最多 ${env.HEALTH_CHECK_TIMEOUT}s..."
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
set -e
for i in \$(seq 1 ${maxAttempts}); do
STATUS=\$(docker inspect --format="{{.State.Health.Status}}" ${env.CONTAINER_NAME} 2>/dev/null || echo "no-healthcheck")
case "\$STATUS" in
healthy)
echo "✅ ${env.APP_NAME} healthy"; exit 0;;
unhealthy)
echo "❌ ${env.APP_NAME} unhealthy"
docker logs --tail 100 ${env.CONTAINER_NAME}
exit 1;;
no-healthcheck)
if curl -fsS http://localhost:${env.HOST_PORT}/ -o /dev/null; then
echo "✅ ${env.APP_NAME} responds 200"; exit 0
fi;;
esac
ELAPSED=\$((i * ${env.HEALTH_CHECK_INTERVAL}))
echo "⏳ \${ELAPSED}s/${env.HEALTH_CHECK_TIMEOUT}s ..."
sleep ${env.HEALTH_CHECK_INTERVAL}
done
echo "❌ Health check timeout"
docker logs --tail 100 ${env.CONTAINER_NAME} || true
exit 1
'
"""
}
def rollback(String sshOpts) {
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
set -e
cd ${env.DEPLOY_PATH}
export IMAGE_TAG=${env.PREVIOUS_IMAGE_TAG}
export REGISTRY_HOST=${env.REGISTRY}
export HOST_PORT=${env.HOST_PORT}
docker compose -f docker-compose.frontend.yml pull
docker compose -f docker-compose.frontend.yml up -d --force-recreate
'
"""
echo " Rolled back ${env.APP_NAME} to ${env.PREVIOUS_IMAGE_TAG}"
}
def checkRemoteDiskOrFail(String host, String role) {
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
def minPct = Integer.parseInt(env.DISK_FREE_MIN_PCT)
def warnPct = Integer.parseInt(env.DISK_FREE_WARN_PCT)
def freePct
try {
freePct = sh(
script: "ssh ${sshOpts} root@${host} \"df -P / | awk 'NR==2 { print 100 - \\\$5+0 }'\"",
returnStdout: true
).trim() as int
} catch (Exception e) {
error("❌ [${role}] SSH 到 ${host} 失败:${e.message}")
}
echo " ${role}@${host}: 根分区空闲 ${freePct}%"
if (freePct < minPct) {
error("❌ [${role}] ${host} 空闲仅 ${freePct}% < ${minPct}%,终止部署")
} else if (freePct < warnPct) {
echo "⚠️ [${role}] ${host} 空闲 ${freePct}% < ${warnPct}%,部署后建议清理"
}
}