diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..140a290 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +** +!Dockerfile.backend +!admin-web/** +!backend/** + +admin-web/node_modules/ +admin-web/dist/ +admin-web/.env* +backend/**/bin/ +backend/**/obj/ +backend/**/TestResults/ +backend/MiaoJiZhang.Api/wwwroot/assets/ +backend/*.log diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..6925ada --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS admin-build +WORKDIR /src/admin-web +COPY admin-web/package.json admin-web/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund +COPY admin-web/ ./ +RUN npm run build + +FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS api-build +WORKDIR /src +COPY backend/MiaoJiZhang.sln backend/ +COPY backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj backend/MiaoJiZhang.Api/ +COPY backend/MiaoJiZhang.Domain/MiaoJiZhang.Domain.csproj backend/MiaoJiZhang.Domain/ +COPY backend/MiaoJiZhang.Infrastructure/MiaoJiZhang.Infrastructure.csproj backend/MiaoJiZhang.Infrastructure/ +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet restore backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj +COPY backend/ backend/ +ARG BUILD_VERSION=dev +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet publish backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj \ + --configuration Release \ + --output /app/publish \ + --no-restore \ + --property:UseAppHost=false \ + --property:InformationalVersion="$BUILD_VERSION" \ + && rm -rf /app/publish/wwwroot \ + && mkdir -p /app/publish/wwwroot +COPY --from=admin-build /src/admin-web/dist/ /app/publish/wwwroot/ + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS runtime +ARG BUILD_VERSION=dev +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +LABEL org.opencontainers.image.title="JiZhi Backend" \ + org.opencontainers.image.description="JiZhi admin web and ASP.NET backend" \ + org.opencontainers.image.version="$BUILD_VERSION" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.source="https://gitea.nxsir.cn/nanxun/jizhi" +WORKDIR /app +COPY --from=api-build --chown=$APP_UID:$APP_UID /app/publish/ ./ +ENV ASPNETCORE_HTTP_PORTS=8080 \ + DOTNET_EnableDiagnostics=0 +USER $APP_UID +EXPOSE 8080 +ENTRYPOINT ["dotnet", "MiaoJiZhang.Api.dll"] diff --git a/Jenkinsfile.backend-docker b/Jenkinsfile.backend-docker new file mode 100644 index 0000000..c8768df --- /dev/null +++ b/Jenkinsfile.backend-docker @@ -0,0 +1,247 @@ +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}" + 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 version >/dev/null + docker 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 }') + test "$available_kb" -ge 8388608 + ''' + } + } + + stage('Build linux-amd64 Image') { + steps { + retry(2) { + sh ''' + set -euo pipefail + docker 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" \ + . + ''' + } + sh ''' + set -euo pipefail + test "$(docker image inspect "$IMAGE_REF" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64' + test "$(docker image inspect "$IMAGE_REF" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$FULL_SHA" + docker 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 network create "$SMOKE_NETWORK" >/dev/null + docker 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 exec "$SMOKE_MYSQL" mysqladmin ping \ + --user=jizhi --password=jizhi-smoke-password --silent; then + mysql_ready=1 + break + fi + sleep 2 + done + if [ "$mysql_ready" != 1 ]; then + docker logs "$SMOKE_MYSQL" >&2 + exit 1 + fi + + docker run --detach \ + --name "$SMOKE_APP" \ + --network "$SMOKE_NETWORK" \ + --publish 127.0.0.1::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 + + host_port=$(docker port "$SMOKE_APP" 8080/tcp | sed -n 's/.*://p' | head -n 1) + test -n "$host_port" + 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 inspect "$SMOKE_APP" --format '{{.State.Running}}' | grep -qx true; then + docker logs "$SMOKE_APP" >&2 + exit 1 + fi + sleep 2 + done + if [ "$app_ready" != 1 ]; then + docker logs "$SMOKE_APP" >&2 + exit 1 + fi + grep -Fq "\"version\":\"$IMMUTABLE_TAG\"" artifacts/smoke-ping.json + curl --fail --silent --show-error "http://127.0.0.1:$host_port/" \ + | grep -Fq '
' + ''' + } + } + + stage('Package Image') { + steps { + sh ''' + set -euo pipefail + docker 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 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) + '\n', 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()) { + sh( + script: ''' + docker rm --force "$SMOKE_APP" "$SMOKE_MYSQL" >/dev/null 2>&1 || true + docker network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true + docker 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) + } + } + } + } +}