339 lines
13 KiB
Plaintext
339 lines
13 KiB
Plaintext
|
|
// ============================================
|
|||
|
|
// 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}"
|
|||
|
|
|
|||
|
|
// ===== 部署目标(默认 PROD,release/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}%,部署后建议清理"
|
|||
|
|
}
|
|||
|
|
}
|