Merge pull request '添加jenkins' (#3) from jenkins/init into main
Reviewed-on: http://103.236.53.208:3000/juwan/juwan-backend/pulls/3
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
// ============================================================
|
||||||
|
// juwan-backend CD Pipeline — Harbor 主动轮询模式
|
||||||
|
// 适用场景:本地开发机无公网,Jenkins 主动检测 Harbor 镜像更新
|
||||||
|
//
|
||||||
|
// 工作原理:
|
||||||
|
// 1. Jenkins 定时(默认每 2 分钟)调用 Harbor Registry API
|
||||||
|
// 2. 对比每个镜像的最新 digest 与上次记录的 digest
|
||||||
|
// 3. 发现变化则触发对应服务的 kubectl rollout restart
|
||||||
|
//
|
||||||
|
// 前置条件(见 docs/jenkins-cd/01-local-dev-setup.md):
|
||||||
|
// - Jenkins 凭据:harbor-credentials(用户名/密码)
|
||||||
|
// - Jenkins 凭据:kubeconfig-dev(Secret file,k3s kubeconfig)
|
||||||
|
// - Jenkins 节点上已安装 kubectl 并可访问 k3s 集群
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
// ── 可调参数 ──────────────────────────────────────────────
|
||||||
|
parameters {
|
||||||
|
// Harbor 地址,不含协议前缀
|
||||||
|
string(
|
||||||
|
name: 'HARBOR_REGISTRY',
|
||||||
|
defaultValue: '103.236.53.208:4418',
|
||||||
|
description: 'Harbor 镜像仓库地址(host:port)'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'HARBOR_PROJECT',
|
||||||
|
defaultValue: 'juwan',
|
||||||
|
description: 'Harbor 项目名'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'K8S_NAMESPACE',
|
||||||
|
defaultValue: 'juwan',
|
||||||
|
description: 'Kubernetes 命名空间'
|
||||||
|
)
|
||||||
|
// 轮询时只检查 latest tag
|
||||||
|
string(
|
||||||
|
name: 'IMAGE_TAG',
|
||||||
|
defaultValue: 'latest',
|
||||||
|
description: '要监听的镜像 Tag'
|
||||||
|
)
|
||||||
|
// 逗号分隔,留空则自动扫描所有服务
|
||||||
|
string(
|
||||||
|
name: 'TARGET_SERVICES',
|
||||||
|
defaultValue: '',
|
||||||
|
description: '指定要部署的服务(逗号分隔,如 user-api,user-rpc)。留空则部署所有有变化的服务'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 触发器:每 2 分钟轮询一次 ────────────────────────────
|
||||||
|
triggers {
|
||||||
|
// cron 表达式:H/2 * * * * → 每 2 分钟
|
||||||
|
// 生产环境改用 Webhook,删除此 triggers 块即可
|
||||||
|
cron('H/2 * * * *')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// Harbor API v2 基础路径
|
||||||
|
HARBOR_API = "http://${params.HARBOR_REGISTRY}/api/v2.0"
|
||||||
|
// digest 状态文件存放目录(Jenkins workspace 内持久化)
|
||||||
|
DIGEST_STATE_DIR = "${WORKSPACE}/.digest-state"
|
||||||
|
// kubectl 命令(节点上的实际路径)
|
||||||
|
KUBECTL = 'kubectl'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
// ── Stage 1:初始化 ───────────────────────────────────
|
||||||
|
stage('Init') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "=== juwan-backend CD Pipeline (Poll Mode) ==="
|
||||||
|
echo "Harbor: ${params.HARBOR_REGISTRY}/${params.HARBOR_PROJECT}"
|
||||||
|
echo "Namespace: ${params.K8S_NAMESPACE}"
|
||||||
|
echo "Tag: ${params.IMAGE_TAG}"
|
||||||
|
|
||||||
|
// 创建 digest 状态目录(跨构建持久化)
|
||||||
|
sh "mkdir -p ${DIGEST_STATE_DIR}"
|
||||||
|
|
||||||
|
// 构建要检查的服务列表
|
||||||
|
if (params.TARGET_SERVICES?.trim()) {
|
||||||
|
env.SERVICE_LIST = params.TARGET_SERVICES.trim()
|
||||||
|
} else {
|
||||||
|
// 自动从 deploy/k8s/service 目录扫描所有 Deployment 名称
|
||||||
|
// 命名规律:{service}-{api|rpc|mq}
|
||||||
|
def services = sh(
|
||||||
|
script: '''
|
||||||
|
find deploy/k8s/service -name "*.yaml" \
|
||||||
|
| xargs grep -l "kind: Deployment" \
|
||||||
|
| xargs -I{} sh -c 'grep "^ name:" {} | head -1 | awk "{print \\$2}"' \
|
||||||
|
| sort -u \
|
||||||
|
| tr "\\n" ","
|
||||||
|
''',
|
||||||
|
returnStdout: true
|
||||||
|
).trim().replaceAll(/,$/, '')
|
||||||
|
env.SERVICE_LIST = services
|
||||||
|
}
|
||||||
|
echo "监控服务列表: ${env.SERVICE_LIST}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 2:检测镜像变化 ─────────────────────────────
|
||||||
|
stage('Detect Changes') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(
|
||||||
|
credentialsId: 'harbor-credentials',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASS'
|
||||||
|
)
|
||||||
|
]) {
|
||||||
|
def serviceList = env.SERVICE_LIST.split(',').collect { it.trim() }.findAll { it }
|
||||||
|
def changedServices = []
|
||||||
|
|
||||||
|
serviceList.each { svc ->
|
||||||
|
if (!svc) return
|
||||||
|
|
||||||
|
echo "检查镜像: ${svc}:${params.IMAGE_TAG}"
|
||||||
|
|
||||||
|
// 调用 Harbor API 获取最新 digest
|
||||||
|
def digestResult = sh(
|
||||||
|
script: """
|
||||||
|
curl -s -u "\${HARBOR_USER}:\${HARBOR_PASS}" \\
|
||||||
|
--connect-timeout 10 \\
|
||||||
|
--max-time 30 \\
|
||||||
|
"${HARBOR_API}/projects/${params.HARBOR_PROJECT}/repositories/${svc}/artifacts/${params.IMAGE_TAG}" \\
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('digest',''))" 2>/dev/null || echo ""
|
||||||
|
""",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!digestResult) {
|
||||||
|
echo " ⚠️ 无法获取 ${svc} 的 digest,跳过(服务可能尚未推送)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取上次记录的 digest
|
||||||
|
def stateFile = "${DIGEST_STATE_DIR}/${svc}.digest"
|
||||||
|
def lastDigest = ""
|
||||||
|
if (fileExists(stateFile)) {
|
||||||
|
lastDigest = readFile(stateFile).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
echo " 当前 digest: ${digestResult}"
|
||||||
|
echo " 上次 digest: ${lastDigest ?: '(首次检测)'}"
|
||||||
|
|
||||||
|
if (digestResult != lastDigest) {
|
||||||
|
echo " ✅ 检测到变化,加入部署队列"
|
||||||
|
changedServices << svc
|
||||||
|
// 立即更新 digest 记录,防止重复触发
|
||||||
|
writeFile file: stateFile, text: digestResult
|
||||||
|
} else {
|
||||||
|
echo " — 无变化,跳过"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将变化列表传递给下一个 Stage
|
||||||
|
env.CHANGED_SERVICES = changedServices.join(',')
|
||||||
|
echo "需要更新的服务: ${env.CHANGED_SERVICES ?: '(无变化)'}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 3:部署到 k3s ───────────────────────────────
|
||||||
|
stage('Deploy') {
|
||||||
|
when {
|
||||||
|
expression { env.CHANGED_SERVICES?.trim() }
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: 'kubeconfig-dev', variable: 'KUBECONFIG_FILE')
|
||||||
|
]) {
|
||||||
|
def changedList = env.CHANGED_SERVICES.split(',').collect { it.trim() }.findAll { it }
|
||||||
|
|
||||||
|
changedList.each { svc ->
|
||||||
|
echo "=== 部署 ${svc} ==="
|
||||||
|
|
||||||
|
// 验证 Deployment 是否存在
|
||||||
|
def exists = sh(
|
||||||
|
script: """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} get deployment ${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE} \\
|
||||||
|
--ignore-not-found \\
|
||||||
|
-o name 2>/dev/null || echo ""
|
||||||
|
""",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
echo " ⚠️ Deployment ${svc} 不存在于 ${params.K8S_NAMESPACE},跳过"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发滚动重启(imagePullPolicy: Always 会拉取最新 latest 镜像)
|
||||||
|
sh """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} rollout restart deployment/${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE}
|
||||||
|
"""
|
||||||
|
|
||||||
|
// 等待滚动更新完成(超时 3 分钟)
|
||||||
|
def rolloutStatus = sh(
|
||||||
|
script: """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} rollout status deployment/${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE} \\
|
||||||
|
--timeout=180s
|
||||||
|
""",
|
||||||
|
returnStatus: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rolloutStatus == 0) {
|
||||||
|
echo " ✅ ${svc} 部署成功"
|
||||||
|
} else {
|
||||||
|
// 部署失败:回滚并标记失败
|
||||||
|
echo " ❌ ${svc} 部署超时或失败,执行回滚..."
|
||||||
|
sh """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} rollout undo deployment/${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE}
|
||||||
|
"""
|
||||||
|
error("${svc} 部署失败,已回滚")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 4:部署摘要 ─────────────────────────────────
|
||||||
|
stage('Summary') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
if (env.CHANGED_SERVICES?.trim()) {
|
||||||
|
echo "=== 本次部署完成 ==="
|
||||||
|
echo "已更新服务: ${env.CHANGED_SERVICES}"
|
||||||
|
} else {
|
||||||
|
echo "=== 无服务需要更新 ==="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
failure {
|
||||||
|
echo "❌ Pipeline 失败,请检查上方日志"
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "✅ Pipeline 执行完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// ============================================================
|
||||||
|
// juwan-backend CD Pipeline — Harbor Webhook 模式
|
||||||
|
// 适用场景:生产环境,Harbor 推送镜像后主动通知 Jenkins
|
||||||
|
//
|
||||||
|
// 工作原理:
|
||||||
|
// 1. Harbor 配置 Webhook,推送事件发送 POST 到 Jenkins
|
||||||
|
// 2. Jenkins Generic Webhook Trigger 插件解析 payload
|
||||||
|
// 3. 提取镜像名称,触发对应 Deployment 的滚动更新
|
||||||
|
//
|
||||||
|
// 前置条件(见 docs/jenkins-cd/02-production-webhook-setup.md):
|
||||||
|
// - Jenkins 插件:Generic Webhook Trigger
|
||||||
|
// - Jenkins 凭据:kubeconfig-prod(Secret file,生产 kubeconfig)
|
||||||
|
// - Harbor Webhook 配置指向本 Jenkins URL
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
// ── Generic Webhook Trigger 插件配置 ─────────────────────
|
||||||
|
// Harbor push 事件 payload 结构:
|
||||||
|
// {
|
||||||
|
// "type": "PUSH_ARTIFACT",
|
||||||
|
// "event_data": {
|
||||||
|
// "resources": [{
|
||||||
|
// "resource_url": "harbor.example.com/juwan/user-api:abc1234"
|
||||||
|
// }],
|
||||||
|
// "repository": { "name": "user-api" }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
triggers {
|
||||||
|
GenericTrigger(
|
||||||
|
genericVariables: [
|
||||||
|
// 提取镜像仓库名(即服务名,如 user-api)
|
||||||
|
[key: 'REPO_NAME', value: '$.event_data.repository.name'],
|
||||||
|
// 提取完整镜像 URL(含 tag)
|
||||||
|
[key: 'RESOURCE_URL', value: '$.event_data.resources[0].resource_url'],
|
||||||
|
// 提取事件类型(只处理 PUSH_ARTIFACT)
|
||||||
|
[key: 'EVENT_TYPE', value: '$.type']
|
||||||
|
],
|
||||||
|
// Webhook token,在 Harbor 配置 Webhook URL 时附加:
|
||||||
|
// http://jenkins.example.com/generic-webhook-trigger/invoke?token=JUWAN_CD_TOKEN
|
||||||
|
token: 'JUWAN_CD_TOKEN',
|
||||||
|
// 只处理推送事件
|
||||||
|
regexpFilterText: '$EVENT_TYPE',
|
||||||
|
regexpFilterExpression: 'PUSH_ARTIFACT',
|
||||||
|
causeString: 'Harbor push: $REPO_NAME ($RESOURCE_URL)',
|
||||||
|
printContributedVariables: true,
|
||||||
|
printPostContent: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(
|
||||||
|
name: 'HARBOR_REGISTRY',
|
||||||
|
defaultValue: 'harbor.example.com',
|
||||||
|
description: 'Harbor 镜像仓库地址'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'HARBOR_PROJECT',
|
||||||
|
defaultValue: 'juwan',
|
||||||
|
description: 'Harbor 项目名'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'K8S_NAMESPACE',
|
||||||
|
defaultValue: 'juwan',
|
||||||
|
description: 'Kubernetes 命名空间'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'KUBECONFIG_CREDENTIAL',
|
||||||
|
defaultValue: 'kubeconfig-prod',
|
||||||
|
description: 'Jenkins 中存储生产 kubeconfig 的凭据 ID'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
KUBECTL = 'kubectl'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
stage('Validate Trigger') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "=== Harbor Webhook 触发 ==="
|
||||||
|
echo "事件类型: ${env.EVENT_TYPE}"
|
||||||
|
echo "仓库名称: ${env.REPO_NAME}"
|
||||||
|
echo "镜像 URL: ${env.RESOURCE_URL}"
|
||||||
|
|
||||||
|
if (!env.REPO_NAME?.trim()) {
|
||||||
|
error("Webhook payload 中未找到 repository.name,请检查 Harbor Webhook 配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def svc = env.REPO_NAME.trim()
|
||||||
|
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: params.KUBECONFIG_CREDENTIAL, variable: 'KUBECONFIG_FILE')
|
||||||
|
]) {
|
||||||
|
// 验证 Deployment 存在
|
||||||
|
def exists = sh(
|
||||||
|
script: """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} get deployment ${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE} \\
|
||||||
|
--ignore-not-found \\
|
||||||
|
-o name 2>/dev/null || echo ""
|
||||||
|
""",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
error("Deployment ${svc} 不存在于命名空间 ${params.K8S_NAMESPACE}")
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "触发滚动更新: ${svc}"
|
||||||
|
|
||||||
|
// 用 image tag 中的 digest/sha 更新镜像,确保精确版本
|
||||||
|
// resource_url 格式: harbor.example.com/juwan/user-api:abc1234
|
||||||
|
def imageRef = env.RESOURCE_URL.trim()
|
||||||
|
|
||||||
|
sh """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} set image deployment/${svc} \\
|
||||||
|
${svc}=${imageRef} \\
|
||||||
|
-n ${params.K8S_NAMESPACE}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def rolloutStatus = sh(
|
||||||
|
script: """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} rollout status deployment/${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE} \\
|
||||||
|
--timeout=300s
|
||||||
|
""",
|
||||||
|
returnStatus: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rolloutStatus == 0) {
|
||||||
|
echo "✅ ${svc} 部署成功 → ${imageRef}"
|
||||||
|
} else {
|
||||||
|
echo "❌ ${svc} 部署失败,执行回滚..."
|
||||||
|
sh """
|
||||||
|
KUBECONFIG=\${KUBECONFIG_FILE} ${KUBECTL} rollout undo deployment/${svc} \\
|
||||||
|
-n ${params.K8S_NAMESPACE}
|
||||||
|
"""
|
||||||
|
error("${svc} 部署失败,已回滚")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "✅ ${env.REPO_NAME} 部署完成"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "❌ ${env.REPO_NAME} 部署失败,请检查日志"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Jenkins Job 配置说明
|
||||||
|
# 本文件描述如何在 Jenkins 中创建对应的 Pipeline Job
|
||||||
|
# 详细步骤见 docs/jenkins-cd/01-local-dev-setup.md
|
||||||
|
|
||||||
|
# ── 轮询模式 Job(本地开发)────────────────────────────────
|
||||||
|
poll_job:
|
||||||
|
name: "juwan-backend-cd-poll"
|
||||||
|
type: Pipeline
|
||||||
|
pipeline_script_from_scm: true
|
||||||
|
scm:
|
||||||
|
type: Git
|
||||||
|
# 填写你的 Gitea 仓库地址
|
||||||
|
url: "http://103.236.53.208:3000/YOUR_ORG/juwan-backend.git"
|
||||||
|
branch: "*/main"
|
||||||
|
credentials_id: "gitea-credentials"
|
||||||
|
script_path: "deploy/jenkins/Jenkinsfile.poll"
|
||||||
|
# 不勾选 SCM 轮询(触发器在 Jenkinsfile 内用 cron 定义)
|
||||||
|
build_triggers:
|
||||||
|
poll_scm: false
|
||||||
|
|
||||||
|
# ── Webhook 模式 Job(生产环境)──────────────────────────────
|
||||||
|
webhook_job:
|
||||||
|
name: "juwan-backend-cd-webhook"
|
||||||
|
type: Pipeline
|
||||||
|
pipeline_script_from_scm: true
|
||||||
|
scm:
|
||||||
|
type: Git
|
||||||
|
url: "https://YOUR_PROD_GIT_REPO/juwan-backend.git"
|
||||||
|
branch: "*/main"
|
||||||
|
credentials_id: "gitea-credentials-prod"
|
||||||
|
script_path: "deploy/jenkins/Jenkinsfile.webhook"
|
||||||
|
build_triggers:
|
||||||
|
generic_webhook_trigger: true
|
||||||
|
# token 与 Jenkinsfile.webhook 中的 token 字段一致
|
||||||
|
token: "JUWAN_CD_TOKEN"
|
||||||
|
|
||||||
|
# ── 所需 Jenkins 凭据 ─────────────────────────────────────
|
||||||
|
credentials:
|
||||||
|
- id: "harbor-credentials"
|
||||||
|
type: "Username with password"
|
||||||
|
description: "Harbor 镜像仓库登录凭据"
|
||||||
|
# 在 Jenkins → Manage Credentials 中创建
|
||||||
|
|
||||||
|
- id: "kubeconfig-dev"
|
||||||
|
type: "Secret file"
|
||||||
|
description: "k3s 开发集群 kubeconfig 文件"
|
||||||
|
# 文件内容:cat ~/.kube/config(或 /etc/rancher/k3s/k3s.yaml)
|
||||||
|
|
||||||
|
- id: "kubeconfig-prod"
|
||||||
|
type: "Secret file"
|
||||||
|
description: "生产 Kubernetes 集群 kubeconfig 文件"
|
||||||
|
|
||||||
|
- id: "gitea-credentials"
|
||||||
|
type: "Username with password"
|
||||||
|
description: "Gitea 仓库访问凭据(用于 SCM checkout)"
|
||||||
Reference in New Issue
Block a user