pipeline { agent { node { label '构建机1' customWorkspace '/home/nanxunai/goujian/workspace/jizhang-backend-docker' } } options { timestamps() disableConcurrentBuilds() skipDefaultCheckout(true) timeout(time: 120, unit: 'MINUTES') buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20')) } environment { PATH = '/usr/local/bin:/usr/bin:/bin' HTTP_PROXY = 'http://192.168.5.200:7890' HTTPS_PROXY = 'http://192.168.5.200:7890' http_proxy = 'http://192.168.5.200:7890' https_proxy = 'http://192.168.5.200:7890' NO_PROXY = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn' no_proxy = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn' OPENLIST_CREDENTIALS = 'openlist_key' OPENLIST_BASE_URL = 'https://openlist.nxsir.cn' OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang/backend-docker/linux-amd64' } stages { stage('Checkout') { steps { deleteDir() checkout scm } } stage('Metadata And Preflight') { steps { script { env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim() env.FULL_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.BUILD_DATE = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() env.BACKEND_VERSION = sh(script: 'date -u +%Y%m%d-%H%M', returnStdout: true).trim() env.IMMUTABLE_TAG = "${env.BACKEND_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}" env.IMAGE_REF = "jizhi-backend:${env.IMMUTABLE_TAG}" env.ARCHIVE_BASENAME = "JiZhi-Backend-${env.IMMUTABLE_TAG}-linux-amd64.tar.gz" env.ARCHIVE_PATH = "${env.WORKSPACE}/artifacts/${env.ARCHIVE_BASENAME}" env.MANIFEST_PATH = "${env.WORKSPACE}/artifacts/JiZhi-Backend-${env.IMMUTABLE_TAG}-manifest.json" env.SMOKE_NETWORK = "jizhi-smoke-net-${env.BUILD_NUMBER}" env.SMOKE_MYSQL = "jizhi-smoke-mysql-${env.BUILD_NUMBER}" env.SMOKE_APP = "jizhi-smoke-app-${env.BUILD_NUMBER}" env.DOCKER_CMD = sh( script: ''' set +x if docker version >/dev/null 2>&1; then printf 'docker' elif sudo -n docker version >/dev/null 2>&1; then printf 'sudo -n docker' else echo 'Jenkins 用户无法访问 Docker;请加入 docker 组或配置免密 sudo docker。' >&2 exit 1 fi ''', returnStdout: true, ).trim() currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.IMMUTABLE_TAG}" currentBuild.description = 'linux/amd64 · Admin Web + ASP.NET API' } sh ''' set -euo pipefail test "$(uname -m)" = x86_64 for command_name in git docker curl gzip sha256sum python3; do command -v "$command_name" >/dev/null done $DOCKER_CMD version >/dev/null $DOCKER_CMD buildx version test -f Dockerfile.backend test -f admin-web/package-lock.json mkdir -p artifacts available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }') # Warm Docker layers consume disk but make incremental builds # much smaller; keep 4 GiB free for image export and smoke tests. test "$available_kb" -ge 4194304 ''' } } stage('Build linux-amd64 Image') { steps { sh ''' set -euo pipefail for attempt in $(seq 1 6); do if $DOCKER_CMD buildx build \ --platform linux/amd64 \ --load \ --file Dockerfile.backend \ --build-arg "BUILD_VERSION=$IMMUTABLE_TAG" \ --build-arg "VCS_REF=$FULL_SHA" \ --build-arg "BUILD_DATE=$BUILD_DATE" \ --tag "$IMAGE_REF" \ .; then break fi if [ "$attempt" -ge 6 ]; then exit 1 fi sleep $((attempt * 10)) done ''' sh ''' set -euo pipefail test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64' test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$FULL_SHA" $DOCKER_CMD run --rm --entrypoint /bin/sh "$IMAGE_REF" -c \ 'test -s /app/MiaoJiZhang.Api.dll && test -s /app/wwwroot/index.html' ''' } } stage('Smoke Test Image') { steps { sh ''' set -euo pipefail $DOCKER_CMD network create "$SMOKE_NETWORK" >/dev/null $DOCKER_CMD run --detach \ --name "$SMOKE_MYSQL" \ --network "$SMOKE_NETWORK" \ --env MYSQL_DATABASE=jizhi_smoke \ --env MYSQL_USER=jizhi \ --env MYSQL_PASSWORD=jizhi-smoke-password \ --env MYSQL_ROOT_PASSWORD=jizhi-smoke-root-password \ mysql:8.4 >/dev/null mysql_ready=0 for attempt in $(seq 1 60); do if $DOCKER_CMD exec "$SMOKE_MYSQL" mysqladmin ping \ --host=127.0.0.1 --protocol=tcp \ --user=jizhi --password=jizhi-smoke-password --silent; then mysql_ready=1 break fi sleep 2 done if [ "$mysql_ready" != 1 ]; then $DOCKER_CMD logs "$SMOKE_MYSQL" >&2 exit 1 fi sleep 2 host_port=$((18080 + BUILD_NUMBER % 1000)) $DOCKER_CMD run --detach \ --name "$SMOKE_APP" \ --network "$SMOKE_NETWORK" \ --publish "127.0.0.1:${host_port}:8080" \ --env ASPNETCORE_ENVIRONMENT=Production \ --env "ConnectionStrings__Default=Server=$SMOKE_MYSQL;Port=3306;Database=jizhi_smoke;User=jizhi;Password=jizhi-smoke-password" \ --env Jwt__Secret=jizhi-smoke-jwt-secret-at-least-32-characters \ --env Jwt__Issuer=MiaoJiZhang.Api \ --env Jwt__Audience=MiaoJiZhang.App \ --env Admin__BootstrapUsername=smoke_admin \ --env Admin__BootstrapPassword=jizhi-smoke-admin-password \ --env Admin__CookieSecure=false \ --env "Build__Version=$IMMUTABLE_TAG" \ "$IMAGE_REF" >/dev/null app_ready=0 for attempt in $(seq 1 90); do if curl --fail --silent --show-error \ "http://127.0.0.1:$host_port/api/ping" >artifacts/smoke-ping.json; then app_ready=1 break fi if ! $DOCKER_CMD inspect "$SMOKE_APP" --format '{{.State.Running}}' | grep -qx true; then $DOCKER_CMD logs "$SMOKE_APP" >&2 exit 1 fi sleep 2 done if [ "$app_ready" != 1 ]; then $DOCKER_CMD logs "$SMOKE_APP" >&2 exit 1 fi python3 - "$IMMUTABLE_TAG" <<'PY' import json, pathlib, sys payload = json.loads(pathlib.Path('artifacts/smoke-ping.json').read_text(encoding='utf-8')) assert payload.get('version') == sys.argv[1], payload PY curl --fail --silent --show-error "http://127.0.0.1:$host_port/" \ | grep -Fq '
' ''' } } stage('Package Image') { steps { sh ''' set -euo pipefail $DOCKER_CMD save "$IMAGE_REF" | gzip -1 >"$ARCHIVE_PATH" gzip -t "$ARCHIVE_PATH" gzip -dc "$ARCHIVE_PATH" | tar -tf - | grep -q '^manifest.json$' sha256sum "$ARCHIVE_PATH" >"$ARCHIVE_PATH.sha256" image_id=$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Id}}') archive_sha=$(awk '{print $1}' "$ARCHIVE_PATH.sha256") export image_id archive_sha python3 - <<'PY' import json, os, pathlib path = pathlib.Path(os.environ['MANIFEST_PATH']) payload = { 'project': 'jizhang-backend', 'buildNumber': os.environ['BUILD_NUMBER'], 'version': os.environ['IMMUTABLE_TAG'], 'commit': os.environ['FULL_SHA'], 'platform': 'linux/amd64', 'image': os.environ['IMAGE_REF'], 'imageId': os.environ['image_id'], 'archive': pathlib.Path(os.environ['ARCHIVE_PATH']).name, 'archiveSha256': os.environ['archive_sha'], 'builtAt': os.environ['BUILD_DATE'], 'contents': ['admin-web', 'MiaoJiZhang.Api'], 'smokeTests': ['image architecture', '/api/ping', 'admin web index'], } path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + chr(10), encoding='utf-8') PY sha256sum "$MANIFEST_PATH" >"$MANIFEST_PATH.sha256" ''' } } stage('Upload To OpenList') { steps { withCredentials([usernamePassword(credentialsId: "${OPENLIST_CREDENTIALS}", usernameVariable: 'OPENLIST_USERNAME', passwordVariable: 'OPENLIST_PASSWORD')]) { sh ''' set -euo pipefail for artifact in \ "$ARCHIVE_PATH" \ "$ARCHIVE_PATH.sha256" \ "$MANIFEST_PATH" \ "$MANIFEST_PATH.sha256"; do ./scripts/upload-openlist-artifact.sh "$artifact" "$OPENLIST_REMOTE_DIR" done ''' } } } } post { always { script { if (env.NODE_NAME?.trim() && env.DOCKER_CMD?.trim()) { sh( script: ''' $DOCKER_CMD rm --force "$SMOKE_APP" "$SMOKE_MYSQL" >/dev/null 2>&1 || true $DOCKER_CMD network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true $DOCKER_CMD image rm "$IMAGE_REF" >/dev/null 2>&1 || true ''', returnStatus: true, ) } } } success { archiveArtifacts artifacts: 'artifacts/*.json,artifacts/*.sha256', fingerprint: true } cleanup { script { if (env.NODE_NAME?.trim()) { cleanWs(deleteDirs: true, notFailBuild: true) } } } } }