diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 6e27f81ae..90caef5ae 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -267,7 +267,7 @@ jobs: docker.m.daocloud.io/library/node:20 \ sh -lc 'npx vitest run src/test' deploy-staging: - name: Deploy Staging + name: Build & Push Staging (Watchtower auto-deploy) runs-on: saas needs: [validate, frontend-lint] @@ -280,11 +280,10 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - python3 - <<'PY' + python3 - <<'INNERPY' import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - # Retry up to 5 times with backoff for transient 5xx errors last_err = None for attempt in range(5): try: @@ -306,7 +305,6 @@ jobs: print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") time.sleep(wait) continue - raise else: raise last_err with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: @@ -319,220 +317,85 @@ jobs: member.name = name[len(root_prefix):] if member.name: tar.extract(member, '.') - PY + INNERPY - - name: Install SSH client + - name: Build and push all images to Gitea Registry shell: sh - run: | - set -eu - apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 - echo "openssh-client installed" - - - name: Build staging web artifact - shell: sh - run: | - set -eu - docker run --rm \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npm ci && npm run build' - docker build --pull=false \ - -f infra/docker/web-artifact.Dockerfile \ - --build-arg NGINX_CONF=infra/docker/nginx-staging.conf \ - -t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \ - . - test -f apps/web/dist/index.html - - - - name: Build and push staging API/Worker images - shell: bash env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu - REGISTRY="172.30.18.198:5000" - CACHE_REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - CACHE_BRANCH="${GITHUB_REF_NAME//\//-}" + chmod +x scripts/build_release_images.sh + ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_SHA}" - # 登录 Gitea Registry(用于构建缓存,PAT 带 packages 权限) - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin - - # 登录内网 Registry(运行时镜像推送目标) - printf '%s' "Xiaoxia2026" | docker login 172.30.18.198:5000 -u admin --password-stdin 2>/dev/null || true - - # 用默认 docker driver builder(共享 docker daemon 凭证,解决 buildx 认证问题) - docker buildx use default 2>/dev/null || { - echo "Warning: default builder not available, buildx cache may not work" - } - - # 验证 builder 状态 - docker buildx ls - - # 构建 API(带分布式缓存,缓存存 Gitea Registry,镜像推内网 Registry) - docker buildx build \ - --cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},ignore-error=true" \ - --cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},mode=max" \ - -f infra/docker/api.Dockerfile \ - -t "${REGISTRY}/xiaoxia-saas-api:dev" \ - --push \ - . - - # 构建 Worker(带分布式缓存) - docker buildx build \ - --cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},ignore-error=true" \ - --cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},mode=max" \ - -f infra/docker/worker.Dockerfile \ - -t "${REGISTRY}/xiaoxia-saas-worker:dev" \ - --push \ - . - - - name: Package staging release artifact - shell: sh - run: | - set -eu - rm -rf dist/staging-artifacts - mkdir -p dist/staging-artifacts - tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \ - -czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz . - docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}" - - - name: Upload staging artifact to business host + - name: Tag and push :staging images (Watchtower auto-update) shell: sh env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu - staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - staging_user="${STAGING_SSH_USER:-root}" - mkdir -p ~/.ssh - # Prefer host key if available (more reliable), fallback to secrets - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using host SSH key: $key_path" - elif [ -n "${STAGING_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" - chmod 600 "$key_path" - echo "Using secret SSH key: $key_path" - else - echo "ERROR: No SSH key available" - exit 1 + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + if [ -n "${REGISTRY_TOKEN:-}" ]; then + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null fi - ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts - ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts" - scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \ - "$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" - scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \ - "$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" + for svc in api worker web; do + docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging" + docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging" + done + echo "All :staging images pushed. Watchtower will auto-deploy within 60s." - - name: Deploy staging stack on business host + - name: Wait for Watchtower update + smoke test shell: sh - env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} run: | set -eu - staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - staging_user="${STAGING_SSH_USER:-root}" - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - elif [ -n "${STAGING_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - else - echo "ERROR: No SSH key available" + echo "Waiting 90s for Watchtower to detect new image and restart containers..." + sleep 90 + + echo "--- Smoke test 1: Health check ---" + for i in $(seq 1 12); do + HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break + echo " Attempt $i/12: not ready yet, waiting 5s..." + sleep 5 + done + if [ -z "$HEALTH" ]; then + echo "FAIL: health endpoint unreachable after 60s" exit 1 fi - echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTEgUkVHSVNUUllfUEFTUz0iWGlhb3hpYVJlZ2lzdHJ5MjAyNiIgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh" + echo "Health OK: $HEALTH" - - name: Post-deploy smoke test - shell: sh - env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - run: | - set -eu - staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - staging_user="${STAGING_SSH_USER:-root}" - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - elif [ -n "${STAGING_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - else - echo "ERROR: No SSH key available" + echo "--- Smoke test 2: Login API (expect 401) ---" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ + https://staging-api.xiaoxiajianji.com/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke@test.com","password":"wrong"}') + if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then + echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" exit 1 fi + echo "Login API OK: HTTP $HTTP_CODE" - echo "Running post-deploy smoke tests on staging..." + echo "--- Smoke test 3: API docs endpoint ---" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging-api.xiaoxiajianji.com/docs) + if [ "$HTTP_CODE" != "200" ]; then + echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" + exit 1 + fi + echo "Docs endpoint OK: HTTP $HTTP_CODE" - # Wait for service to fully start - sleep 5 + echo "--- Smoke test 4: Web frontend ---" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging.xiaoxiajianji.com/) + if [ "$HTTP_CODE" != "200" ]; then + echo "FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)" + exit 1 + fi + echo "Web frontend OK: HTTP $HTTP_CODE" - # Run smoke tests via SSH on the business host - ssh -i "$key_path" "$staging_user@$staging_host" ' - echo "--- Smoke test 1: Health check ---" - HEALTH=$(curl -sf --max-time 10 http://127.0.0.1:8000/health) || { - echo "FAIL: health endpoint unreachable" - exit 1 - } - echo "Health OK: $HEALTH" + echo "" + echo "=== All smoke tests passed! ===" + echo "Branch: ${GITHUB_REF_NAME}" + echo "Commit: ${GITHUB_SHA}" - echo "--- Smoke test 2: Login API (expect 401) ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ - http://127.0.0.1:8000/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d "{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}") - - if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then - echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" - exit 1 - fi - echo "Login API OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 3: API docs endpoint ---" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/docs) - if [ "$HTTP_CODE" != "200" ]; then - echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" - exit 1 - fi - echo "Docs endpoint OK: HTTP $HTTP_CODE" - - echo "--- Smoke test 4: Network isolation verification ---" - # Verify staging containers are on the staging network - STAGING_NET=$(docker inspect xiaoxia-api-staging --format="{{json .NetworkSettings.Networks}}" 2>/dev/null) - if [ -z "$STAGING_NET" ]; then - echo "WARN: Could not inspect staging container networks (container may not exist yet)" - else - echo "Staging API container networks: $STAGING_NET" - if echo "$STAGING_NET" | grep -q "xiaoxia-net-staging"; then - echo "Network isolation OK: staging containers on xiaoxia-net-staging" - else - echo "WARN: staging containers not on expected xiaoxia-net-staging network" - echo " Current networks: $STAGING_NET" - fi - fi - - # Verify cross-environment DNS isolation - # staging API should resolve to staging container, not production - STAGING_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-staging 2>/dev/null | awk "{print \$1}" || true) - PRODUCTION_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-production 2>/dev/null | awk "{print \$1}" || true) - if [ -n "$STAGING_API_IP" ]; then - echo "Staging API resolves to: $STAGING_API_IP (from web container)" - fi - if [ -n "$PRODUCTION_API_IP" ]; then - echo "FAIL: staging web container can resolve production API address ($PRODUCTION_API_IP) - network isolation broken!" - exit 1 - else - echo "Network isolation OK: staging web cannot resolve xiaoxia-api-production" - fi - - echo "" - echo "=== All smoke tests passed! ===" - ' staging-e2e: name: Staging E2E Tests @@ -647,41 +510,6 @@ jobs: chmod +x scripts/build_release_images.sh REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" - - name: Package release source artifact - shell: sh - run: | - set -eu - mkdir -p dist/release-artifacts - tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \ - -czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" . - - - name: Upload source artifact to production - shell: sh - env: - PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} - PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} - PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} - run: | - set -eu - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - mkdir -p ~/.ssh - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - echo "Using host SSH key: $key_path" - elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path" - chmod 600 "$key_path" - echo "Using secret SSH key: $key_path" - else - echo "ERROR: No SSH key available" - exit 1 - fi - ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts - scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \ - "$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz" - - name: Cleanup old Docker images if: always() shell: sh @@ -711,7 +539,7 @@ jobs: apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 echo "openssh-client installed" - - name: Deploy production over SSH (pull images from registry) + - name: Deploy production over SSH (Registry pull) shell: sh env: PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} @@ -735,10 +563,10 @@ jobs: fi ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts - # 部署脚本(base64 编码避免转义问题) - DEPLOY_B64="c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uCm9sZF9hc3NldHNfZGlyPSIvdG1wL3hpYW94aWEtcHJldmlvdXMtd2ViLWFzc2V0cy0ke1JFTEVBU0VfVkVSU0lPTn0iCnJtIC1yZiAiJG9sZF9hc3NldHNfZGlyIgpta2RpciAtcCAiJG9sZF9hc3NldHNfZGlyIgppZiBkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uID4vZGV2L251bGwgMj4mMTsgdGhlbgogIGRvY2tlciBjcCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uOi91c3Ivc2hhcmUvbmdpbngvaHRtbC9hc3NldHMvLiAiJG9sZF9hc3NldHNfZGlyLyIgMj4vZGV2L251bGwgfHwgdHJ1ZQpmaQppZiBbIC1kIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMgXTsgdGhlbgogIGNwIC1hIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvLiAiJG9sZF9hc3NldHNfZGlyLyIKZmkKcm0gLXJmIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KbWtkaXIgLXAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0YXIgLXh6ZiAiJHJlbGVhc2VfdGFyIiAtQyAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCnRlc3QgLWYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2luZGV4Lmh0bWwKaWYgWyAtZCAiJG9sZF9hc3NldHNfZGlyIiBdOyB0aGVuCiAgbWtkaXIgLXAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cwogIGZvciBhc3NldCBpbiAiJG9sZF9hc3NldHNfZGlyIi8qOyBkbwogICAgWyAtZSAiJGFzc2V0IiBdIHx8IGNvbnRpbnVlCiAgICBuYW1lPSIkKGJhc2VuYW1lICIkYXNzZXQiKSIKICAgIGlmIFsgISAtZSAiL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cy8kbmFtZSIgXTsgdGhlbgogICAgICBjcCAtYSAiJGFzc2V0IiAiL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cy8kbmFtZSIKICAgIGZpCiAgZG9uZQogIHJtIC1yZiAiJG9sZF9hc3NldHNfZGlyIgpmaQp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYKY3AgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvLy5lbnYKZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpSRUdJU1RSWV9UT0tFTj0iJHtSRUdJU1RSWV9UT0tFTn0iIFJFTEVBU0VfVkVSU0lPTj0iJHtSRUxFQVNFX1ZFUlNJT059IiBIT1NUX1BSRUZJWD0gRU5WPXByb2R1Y3Rpb24gc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==" + # Registry 方式部署脚本(base64 编码避免转义问题) + DEPLOY_B64="IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=" - echo "$DEPLOY_B64" | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" + echo "$DEPLOY_B64" | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" production-e2e: name: Production Browser E2E diff --git a/infra/docker/deploy-production-registry.sh b/infra/docker/deploy-production-registry.sh new file mode 100755 index 000000000..58bf933a0 --- /dev/null +++ b/infra/docker/deploy-production-registry.sh @@ -0,0 +1,240 @@ +#!/bin/sh +set -eu + +# ============================================ +# Production 部署脚本 - Registry 方式 +# 用法:IMAGE_TAG= REGISTRY_TOKEN= sh deploy-production-registry.sh +# ============================================ + +IMAGE_TAG="${IMAGE_TAG:-}" +REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" +REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" +REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" + +ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}" +GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}" +LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}" + +if [ -z "$IMAGE_TAG" ]; then + echo "ERROR: IMAGE_TAG is required" + exit 1 +fi + +test -f "$ENV_FILE" +mkdir -p "$GENERATED_DIR" +mkdir -p "$LEGACY_ASSETS_DIR" + +# ---- 登录 Registry ---- +if [ -n "$REGISTRY_TOKEN" ]; then + echo "Logging in to registry: $REGISTRY" + REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1) + printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || { + echo "WARN: docker login failed, will try to pull anyway" + } +fi + +# ---- Pull 三镜像 ---- +REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" +REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" +REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" + +LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}" +LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}" +LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}" + +echo "Pulling API image..." +docker pull "$REGISTRY_API" +echo "Pulling Worker image..." +docker pull "$REGISTRY_WORKER" +echo "Pulling Web image..." +docker pull "$REGISTRY_WEB" + +# ---- Re-tag 成本地名 ---- +docker tag "$REGISTRY_API" "$LOCAL_API" +docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER" +docker tag "$REGISTRY_WEB" "$LOCAL_WEB" +echo "All images pulled and tagged." + +# ---- 备份旧版 assets(部署期间缓存用户不 404) ---- +echo "Backing up legacy assets from current web container..." +if docker inspect xiaoxia-web-production >/dev/null 2>&1; then + _tmpdir="/tmp/legacy-assets-$$" + rm -rf "$_tmpdir" + mkdir -p "$_tmpdir" + docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true + # 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 assets) + if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then + cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true + echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files" + fi + rm -rf "$_tmpdir" +else + echo "No existing web container, skipping legacy assets backup" +fi + +# 清理超过 7 天的旧 assets 文件(避免无限增长) +if [ -d "$LEGACY_ASSETS_DIR" ]; then + find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true + echo "Legacy assets cleanup done (retain 7 days)" +fi + +# ---- 确保基础设施容器在运行 ---- +echo "Checking infrastructure containers..." +for c in xiaoxia-postgres-production xiaoxia-redis-production; do + if ! docker inspect "$c" >/dev/null 2>&1; then + echo "ERROR: Required container not found: $c" + exit 1 + fi + state=$(docker inspect -f '{{.State.Status}}' "$c") + if [ "$state" != "running" ]; then + echo "ERROR: Container not running: $c ($state)" + exit 1 + fi +done + +# ---- 确保生产网络存在 ---- +docker network create xiaoxia-net-production 2>/dev/null || true + +# ---- 执行数据库 Migration ---- +echo "Running database migrations..." +docker run --rm \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + "$LOCAL_API" sh -c "cd /app && alembic upgrade head" +echo "Migrations completed." + +# ---- 停止旧容器 ---- +echo "Stopping old containers..." +docker rm -f xiaoxia-api-production 2>/dev/null || true +docker rm -f xiaoxia-worker-production 2>/dev/null || true +docker rm -f xiaoxia-web-production 2>/dev/null || true + +# ---- 日志配置(所有容器共用) ---- +LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" + +# ---- 启动 API ---- +echo "Starting API container..." +docker run -d \ + --name xiaoxia-api-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -p 127.0.0.1:8001:8000 \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 40s \ + $LOG_OPTS \ + "$LOCAL_API" + +# ---- 启动 Worker ---- +echo "Starting Worker container..." +docker run -d \ + --name xiaoxia-worker-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e WORKER_CONCURRENCY=1 \ + -e WORKER_MAX_TASKS_PER_CHILD=100 \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 30s \ + $LOG_OPTS \ + "$LOCAL_WORKER" + +# ---- 启动 Web ---- +# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/ +# nginx 配置中 assets location 有 fallback 逻辑 +LEGACY_VOLUME="" +if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then + LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" + echo "Web container: legacy assets mounted (fallback)" +else + echo "Web container: no legacy assets to mount" +fi + +echo "Starting Web container..." +docker run -d \ + --name xiaoxia-web-production \ + --network xiaoxia-net-production \ + -p 127.0.0.1:3002:80 \ + --restart unless-stopped \ + --cpus 0.5 \ + --memory 512m \ + $LEGACY_VOLUME \ + --health-cmd "wget --spider -q http://127.0.0.1:80" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + $LOG_OPTS \ + "$LOCAL_WEB" + +# ---- 等待 API 健康 ---- +echo "Waiting for API to become healthy..." +i=0 +while [ "$i" -lt 40 ]; do + if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then + echo "API is healthy!" + break + fi + i=$((i + 1)) + echo " Waiting... ($i/40)" + sleep 3 +done + +if [ "$i" -ge 40 ]; then + echo "ERROR: API did not become healthy within 120s" + docker logs --tail 50 xiaoxia-api-production + exit 1 +fi + +# ---- 等待 Web 健康 ---- +echo "Waiting for Web to become healthy..." +i=0 +while [ "$i" -lt 15 ]; do + if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then + echo "Web is healthy!" + break + fi + i=$((i + 1)) + echo " Waiting... ($i/15)" + sleep 2 +done + +if [ "$i" -ge 15 ]; then + echo "ERROR: Web did not become healthy within 30s" + docker logs --tail 30 xiaoxia-web-production + exit 1 +fi + +# ---- 清理旧镜像 ---- +echo "Cleaning up old images..." +docker image prune -af --filter "until=168h" 2>/dev/null || true +docker builder prune -af --filter "until=168h" 2>/dev/null || true + +echo "" +echo "=== Production deployment complete ===" +echo "API: http://127.0.0.1:8001" +echo "Web: http://127.0.0.1:3002" +echo "Version: $IMAGE_TAG" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production diff --git a/infra/docker/deploy-staging-registry.sh b/infra/docker/deploy-staging-registry.sh new file mode 100755 index 000000000..d9f744a8a --- /dev/null +++ b/infra/docker/deploy-staging-registry.sh @@ -0,0 +1,178 @@ +#!/bin/sh +set -eu + +# ============================================ +# Staging 部署脚本 - Registry 方式 +# 用法:IMAGE_TAG= REGISTRY_TOKEN= sh deploy-staging.sh +# ============================================ + +IMAGE_TAG="${IMAGE_TAG:-}" +REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" +REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" +REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" + +ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}" +COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}" +GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}" + +if [ -z "$IMAGE_TAG" ]; then + echo "ERROR: IMAGE_TAG is required" + exit 1 +fi + +test -f "$ENV_FILE" +mkdir -p "$GENERATED_DIR" + +# ---- 登录 Registry ---- +if [ -n "$REGISTRY_TOKEN" ]; then + echo "Logging in to registry: $REGISTRY" + printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || { + echo "WARN: docker login failed, will try to pull anyway" + } +fi + +# ---- Pull 三镜像 ---- +REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" +REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" +REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" + +LOCAL_API="${REGISTRY}/xiaoxia-saas-api:staging" +LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging" +LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging" + +echo "Pulling API image..." +docker pull "$REGISTRY_API" +echo "Pulling Worker image..." +docker pull "$REGISTRY_WORKER" +echo "Pulling Web image..." +docker pull "$REGISTRY_WEB" + +# ---- Re-tag 成本地名 ---- +docker tag "$REGISTRY_API" "$LOCAL_API" +docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER" +docker tag "$REGISTRY_WEB" "$LOCAL_WEB" +echo "All images pulled and tagged." + +# ---- 确保基础设施容器在运行 ---- +for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do + if ! docker inspect "$c" >/dev/null 2>&1; then + echo "ERROR: Required container not found: $c" + exit 1 + fi + state=$(docker inspect -f {{.State.Status}} "$c") + if [ "$state" != "running" ]; then + echo "ERROR: Container not running: $c ($state)" + exit 1 + fi +done + +# ---- 确保 staging 网络存在 ---- +docker network create xiaoxia-net-staging 2>/dev/null || true + +# ---- 执行数据库 Migration ---- +echo "Running database migrations..." +docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head" +echo "Migrations completed." + +# ---- 停止旧容器 ---- +docker rm -f xiaoxia-api-staging 2>/dev/null || true +docker rm -f xiaoxia-worker-staging 2>/dev/null || true +docker rm -f xiaoxia-web-staging 2>/dev/null || true + +# ---- 启动 API ---- +echo "Starting API container..." +docker run -d \ + --name xiaoxia-api-staging \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-staging \ + -p 127.0.0.1:8000:8000 \ + -e APP_ENV=staging \ + -e APP_VERSION="$IMAGE_TAG" \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --label com.centurylinklabs.watchtower.enable=true \ + --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 40s \ + "$LOCAL_API" + +# ---- 启动 Worker ---- +echo "Starting Worker container..." +docker run -d \ + --name xiaoxia-worker-staging \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-staging \ + -e APP_ENV=staging \ + -e APP_VERSION="$IMAGE_TAG" \ + -e WORKER_CONCURRENCY=1 \ + -e WORKER_MAX_TASKS_PER_CHILD=100 \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --label com.centurylinklabs.watchtower.enable=true \ + --health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 30s \ + "$LOCAL_WORKER" + +# ---- 启动 Web ---- +# Web 镜像默认打包 production nginx.conf,staging 需要挂载 staging 配置 +NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}" +if [ ! -f "$NGINX_CONF" ]; then + echo "WARN: nginx config not found at $NGINX_CONF, using image default" + NGINX_VOLUME="" +else + NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro" +fi + +echo "Starting Web container..." +docker run -d \ + --name xiaoxia-web-staging \ + --network xiaoxia-net-staging \ + -p 127.0.0.1:3001:80 \ + --restart unless-stopped \ + --label com.centurylinklabs.watchtower.enable=true \ + $NGINX_VOLUME \ + --health-cmd "wget --spider -q http://127.0.0.1:80" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + "$LOCAL_WEB" + +# ---- 等待 API 健康 ---- +echo "Waiting for API to become healthy..." +i=0 +while [ "$i" -lt 30 ]; do + if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then + echo "API is healthy!" + break + fi + i=$((i + 1)) + echo " Waiting... ($i/30)" + sleep 2 +done + +if [ "$i" -ge 30 ]; then + echo "ERROR: API did not become healthy within 60s" + docker logs --tail 30 xiaoxia-api-staging + exit 1 +fi + +# ---- 清理旧镜像 ---- +docker image prune -af --filter "until=72h" 2>/dev/null || true + +echo "" +echo "=== Staging deployment complete ===" +echo "API: http://127.0.0.1:8000" +echo "Web: http://127.0.0.1:3001" +echo "Version: $IMAGE_TAG" +docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging + +# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动 diff --git a/infra/docker/nginx-production.conf b/infra/docker/nginx-production.conf index f9fdef9ae..c80cfa7b5 100755 --- a/infra/docker/nginx-production.conf +++ b/infra/docker/nginx-production.conf @@ -39,6 +39,15 @@ server { alias /app/generated/; } + # Assets with legacy fallback (higher priority than generic static regex) + # 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件 + # 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的) + location ^~ /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri /assets-legacy$uri =404; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y;