diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh new file mode 100755 index 00000000..218ee318 --- /dev/null +++ b/scripts/build-frontend.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# +# macOS 版前端构建:与 scripts/build-frontend.ps1 保持相同的输入指纹、缓存与产物布局。 +# 生成物写入 .caiwu-build/public,供 deploy-from-mac.sh 作为镜像构建上下文使用。 +# +# 用法:bash scripts/build-frontend.sh [--publish-local] [--node-version 20.20.2] +# +set -euo pipefail + +NODE_VERSION="${CAIWU_NODE_VERSION:-20.20.2}" +MAX_OLD_SPACE_SIZE_MB="${CAIWU_MAX_OLD_SPACE_MB:-4096}" +TERSER_WORKERS="${CAIWU_TERSER_WORKERS:-2}" +PUBLISH_LOCAL=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --publish-local) PUBLISH_LOCAL=1 ;; + --node-version) NODE_VERSION="$2"; shift ;; + --max-old-space-size-mb) MAX_OLD_SPACE_SIZE_MB="$2"; shift ;; + --terser-workers) TERSER_WORKERS="$2"; shift ;; + -h|--help) sed -n '2,8p' "$0"; exit 0 ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +tool_root="$HOME/.cache/CaiWuXiTong/tools" + +case "$(uname -m)" in + arm64) node_platform='darwin-arm64' ;; + x86_64) node_platform='darwin-x64' ;; + *) echo "不支持的 macOS 架构:$(uname -m)" >&2; exit 1 ;; +esac + +node_folder="node-v$NODE_VERSION-$node_platform" +node_root="$tool_root/$node_folder" +node_exe="$node_root/bin/node" +npm_cmd="$node_root/bin/npm" +node_archive="$tool_root/$node_folder.tar.gz" +node_checksums="$tool_root/SHASUMS256.txt" + +tracked_css="$repo_root/public/css/app.css" +public_js="$repo_root/public/js" +public_manifest="$repo_root/public/mix-manifest.json" +cache_root="$repo_root/.caiwu-build" +output_root="$cache_root/public" +vendor_root="$output_root/vendor" +js_fingerprint_file="$cache_root/frontend-js.sha" +css_fingerprint_file="$cache_root/frontend-css.sha" +dependency_fingerprint_file="$cache_root/node-modules.sha" +js_manifest_cache="$cache_root/manifest-js.json" +css_manifest_cache="$cache_root/manifest-css.json" +output_fingerprint_file="$cache_root/frontend-output.sha" + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +sha256_stdin() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + sha256sum | awk '{print $1}' + fi +} + +path_fingerprint() { + local list_file content path + list_file="$(mktemp)" + + for path in "$@"; do + if [ -f "$repo_root/$path" ]; then + printf '%s\n' "$repo_root/$path" >> "$list_file" + elif [ -d "$repo_root/$path" ]; then + find "$repo_root/$path" -type f >> "$list_file" + else + rm -f "$list_file" + echo "前端指纹路径不存在:$path" >&2 + return 1 + fi + done + + LC_ALL=C sort -o "$list_file" "$list_file" + content="$(while IFS= read -r file; do + printf '%s|%s\n' "${file#"$repo_root"/}" "$(sha256_file "$file")" + done < "$list_file")" + rm -f "$list_file" + printf '%s' "$content" | sha256_stdin +} + +cache_hit() { + local fingerprint_file="$1" expected="$2" + shift 2 + + [ -f "$fingerprint_file" ] || return 1 + for output in "$@"; do + [ -e "$output" ] || return 1 + done + [ "$(cat "$fingerprint_file")" = "$expected" ] +} + +write_file() { + printf '%s' "$2" > "$1" +} + +download_file() { + local url="$1" destination="$2" attempt=1 + + while [ "$attempt" -le 4 ]; do + if curl -fL --retry 3 --retry-delay 2 --retry-all-errors \ + --speed-limit 20000 --speed-time 20 \ + -C - -o "$destination" "$url"; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 2 + done + + return 1 +} + +publish_frontend_cache() { + for required in "$output_root/js" "$output_root/css/app.css" "$output_root/mix-manifest.json"; do + [ -e "$required" ] || { echo "前端缓存产物缺失:$required" >&2; exit 1; } + done + + rm -rf "$public_js" + cp -R "$output_root/js" "$public_js" + cp "$output_root/css/app.css" "$tracked_css" + cp "$output_root/mix-manifest.json" "$public_manifest" +} + +package_lock_fingerprint="$(path_fingerprint package-lock.json)" +js_fingerprint="$NODE_VERSION|$TERSER_WORKERS|$(path_fingerprint resources/assets/js package.json package-lock.json scripts/build-frontend.ps1 scripts/build-frontend.sh webpack.mix.js)" +css_fingerprint="$NODE_VERSION|$(path_fingerprint resources/assets/sass resources/views resources/lang package.json package-lock.json webpack.mix.js tailwind.config.js presets.js safelist.txt composer.lock)" +dependency_fingerprint="$NODE_VERSION|$package_lock_fingerprint" + +output_cache_valid=0 +if [ -f "$output_fingerprint_file" ] && [ -d "$output_root" ] \ + && [ "$(cat "$output_fingerprint_file")" = "$(path_fingerprint .caiwu-build/public)" ]; then + output_cache_valid=1 +fi + +js_cache_valid=0 +if cache_hit "$js_fingerprint_file" "$js_fingerprint" \ + "$output_root/js/auth/common.min.js" \ + "$output_root/js/business/pages.min.js" \ + "$output_root/js/business/marketing-account-bills.min.js" \ + "$output_root/js/manifest.js" \ + "$output_root/js/vendor.js" \ + "$vendor_root/apexcharts/apexcharts.min.js" \ + "$vendor_root/js-cookie/js.cookie.js" \ + "$vendor_root/quicksand/css/quicksand.css" \ + "$vendor_root/quicksand/files/quicksand-latin-400-normal.woff2" \ + "$js_manifest_cache"; then + js_cache_valid=1 +fi + +css_cache_valid=0 +if cache_hit "$css_fingerprint_file" "$css_fingerprint" "$output_root/css/app.css" "$css_manifest_cache"; then + css_cache_valid=1 +fi + +if [ "$output_cache_valid" -eq 0 ]; then + js_cache_valid=0 + css_cache_valid=0 + echo '前端产物完整性缺失或已变化,重新构建资源。' +fi + +if [ "$js_cache_valid" -eq 1 ] && [ "$css_cache_valid" -eq 1 ]; then + echo '前端输入未变化,复用 JavaScript、CSS 与依赖缓存。' + if [ "$PUBLISH_LOCAL" -eq 1 ]; then + publish_frontend_cache + echo '已把缓存中的前端资源发布到本地 public 目录。' + fi + exit 0 +fi + +if [ ! -x "$node_exe" ]; then + mkdir -p "$tool_root" + download_root="https://nodejs.org/dist/v$NODE_VERSION" + if [ -n "${CAIWU_NODE_MIRROR:-}" ]; then + download_sources="$CAIWU_NODE_MIRROR" + else + download_sources="$download_root https://registry.npmmirror.com/-/binary/node/v$NODE_VERSION" + fi + + echo "下载项目专用 Node ${NODE_VERSION}(${node_platform})。" + node_ready=0 + for download_source in $download_sources; do + if download_file "$download_source/$node_folder.tar.gz" "$node_archive" \ + && download_file "$download_source/SHASUMS256.txt" "$node_checksums"; then + node_ready=1 + break + fi + echo "下载源 ${download_source} 中断,换下一个源重试。" + rm -f "$node_archive" "$node_checksums" + done + + if [ "$node_ready" -eq 0 ]; then + echo '下载项目专用 Node 失败,请设置 CAIWU_NODE_MIRROR 指向可用的 Node 镜像后重试。' >&2 + exit 1 + fi + + expected_hash="$(awk -v name="$node_folder.tar.gz" '$2 == name { print $1; exit }' "$node_checksums")" + if [ -z "$expected_hash" ]; then + rm -f "$node_archive" "$node_checksums" + echo 'Node 压缩包缺少官方 SHA-256 校验值。' >&2 + exit 1 + fi + + actual_hash="$(sha256_file "$node_archive")" + if [ "$actual_hash" != "$expected_hash" ]; then + rm -f "$node_archive" "$node_checksums" + echo 'Node 压缩包 SHA-256 校验失败。' >&2 + exit 1 + fi + + tar -xzf "$node_archive" -C "$tool_root" + rm -f "$node_archive" "$node_checksums" +fi + +if [ ! -x "$node_exe" ] || [ ! -x "$npm_cmd" ]; then + echo "项目专用 Node $NODE_VERSION 安装不完整。" >&2 + exit 1 +fi + +original_path="$PATH" +tracked_css_backup="" +if [ -f "$tracked_css" ]; then + tracked_css_backup="$(mktemp)" + cp "$tracked_css" "$tracked_css_backup" +fi + +restore_tracked_css() { + if [ "$PUBLISH_LOCAL" -eq 0 ] && [ -n "$tracked_css_backup" ] && [ -f "$tracked_css_backup" ]; then + cp "$tracked_css_backup" "$tracked_css" + fi + [ -n "$tracked_css_backup" ] && rm -f "$tracked_css_backup" +} +trap restore_tracked_css EXIT + +cd "$repo_root" +export PATH="$node_root/bin:$original_path" +export NODE_OPTIONS="--max-old-space-size=$MAX_OLD_SPACE_SIZE_MB" +export CAIWU_TERSER_WORKERS="$TERSER_WORKERS" + +"$node_exe" --version >/dev/null + +dependency_cache_valid=0 +if [ -f "$repo_root/node_modules/.package-lock.json" ] && [ -f "$dependency_fingerprint_file" ] \ + && [ "$(cat "$dependency_fingerprint_file")" = "$dependency_fingerprint" ]; then + dependency_cache_valid=1 +fi + +if [ "$dependency_cache_valid" -eq 1 ]; then + echo 'package-lock.json 未变化,复用 node_modules。' +else + "$npm_cmd" ci --prefer-offline --no-audit --no-fund + mkdir -p "$cache_root" + write_file "$dependency_fingerprint_file" "$dependency_fingerprint" +fi + +if [ "$js_cache_valid" -eq 0 ]; then + echo "构建 JavaScript(Node 堆 ${MAX_OLD_SPACE_SIZE_MB}MB,压缩线程 ${TERSER_WORKERS})。" + rm -rf "$public_js" + CAIWU_BUILD_TARGET=js "$npm_cmd" run production + + for output in \ + public/js/auth/common.min.js \ + public/js/business/marketing-account-bills.min.js \ + public/js/manifest.js \ + public/js/vendor.js \ + public/mix-manifest.json; do + [ -f "$repo_root/$output" ] || { echo "JavaScript 构建产物缺失:$output" >&2; exit 1; } + done + + rm -rf "$output_root/js" "$vendor_root" + mkdir -p "$output_root" "$vendor_root/apexcharts" "$vendor_root/js-cookie" "$vendor_root/quicksand/css" "$vendor_root/quicksand/files" + cp -R "$public_js" "$output_root/js" + cp "$repo_root/node_modules/apexcharts/dist/apexcharts.min.js" "$vendor_root/apexcharts/apexcharts.min.js" + cp "$repo_root/node_modules/js-cookie/dist/js.cookie.min.js" "$vendor_root/js-cookie/js.cookie.js" + cp -R "$repo_root/node_modules/@fontsource/quicksand/files/." "$vendor_root/quicksand/files/" + + : > "$vendor_root/quicksand/css/quicksand.css" + for weight in 400 500 600 700; do + sed 's#url(\./files/#url(../files/#g' "$repo_root/node_modules/@fontsource/quicksand/$weight.css" >> "$vendor_root/quicksand/css/quicksand.css" + printf '\n' >> "$vendor_root/quicksand/css/quicksand.css" + done + + cp "$public_manifest" "$js_manifest_cache" + write_file "$js_fingerprint_file" "$js_fingerprint" +fi + +if [ "$css_cache_valid" -eq 0 ]; then + echo "构建 CSS(Node 堆 ${MAX_OLD_SPACE_SIZE_MB}MB)。" + CAIWU_BUILD_TARGET=css "$npm_cmd" run production + + for output in public/css/app.css public/mix-manifest.json; do + [ -f "$repo_root/$output" ] || { echo "CSS 构建产物缺失:$output" >&2; exit 1; } + done + + mkdir -p "$output_root/css" + cp "$tracked_css" "$output_root/css/app.css" + cp "$public_manifest" "$css_manifest_cache" + write_file "$css_fingerprint_file" "$css_fingerprint" +fi + +"$node_exe" -e ' +const fs = require("fs"); +const [jsPath, cssPath, outputPath, publicPath] = process.argv.slice(1); +const merged = {}; +for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(jsPath, "utf8")))) { + if (key.endsWith(".js")) merged[key] = value; +} +for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(cssPath, "utf8")))) { + if (key.endsWith(".css")) merged[key] = value; +} +const json = JSON.stringify(merged, null, 4); +fs.writeFileSync(outputPath, json); +fs.writeFileSync(publicPath, json); +' "$js_manifest_cache" "$css_manifest_cache" "$output_root/mix-manifest.json" "$public_manifest" + +if [ "$js_fingerprint" != "$NODE_VERSION|$TERSER_WORKERS|$(path_fingerprint resources/assets/js package.json package-lock.json scripts/build-frontend.ps1 scripts/build-frontend.sh webpack.mix.js)" ] \ + || [ "$css_fingerprint" != "$NODE_VERSION|$(path_fingerprint resources/assets/sass resources/views resources/lang package.json package-lock.json webpack.mix.js tailwind.config.js presets.js safelist.txt composer.lock)" ]; then + echo '构建过程中前端源码发生变化,请在稳定的提交上重试。' >&2 + exit 1 +fi + +write_file "$output_fingerprint_file" "$(path_fingerprint .caiwu-build/public)" +echo '前端构建完成,产物位于 .caiwu-build/public。' diff --git a/scripts/deploy-from-mac.sh b/scripts/deploy-from-mac.sh new file mode 100755 index 00000000..66fd06eb --- /dev/null +++ b/scripts/deploy-from-mac.sh @@ -0,0 +1,593 @@ +#!/usr/bin/env bash +# +# macOS 版正式发布:流程与 scripts/deploy-from-workstation.ps1 一致。 +# 1. 检查分支与未提交改动;2. 本机构建前端;3. 构建并校验应用镜像; +# 4. 通过 SSH 隧道把新增镜像层推送到服务器本机 Registry;5. 服务器激活并健康检查; +# 6. 校验生产页面脚本与本次构建一致;7. 清理临时文件、隧道与构建缓存。 +# +# 用法:bash scripts/deploy-from-mac.sh [选项] +# --server <地址> 默认 218.93.190.75 +# --key <私钥路径> 使用 SSH 私钥(可选;不传则用已保存的密码或系统钥匙串) +# --update-password 重新输入并保存服务器密码 +# --ssh-user <用户> 默认 root +# --remote-path <路径> 默认 /opt/caiwuxitong +# --app-url <地址> 默认 https://caiwu.jiansuntuzhi.cn:8442 +# --bootstrap-server 首次初始化服务器 +# --configure-dingtalk-callback +# --allow-non-main 允许非 main 分支(测试用) +# --allow-migrations 允许执行待办数据库迁移(需先按 AGENTS.md 核对) +# --keep-images <数量> 默认 2 +# --app-version <版本> 默认 3.1.15 +# +set -euo pipefail + +server='218.93.190.75' +key_path='' +update_password=0 +ssh_user='root' +remote_path='/opt/caiwuxitong' +app_url='' +builder_name='caiwu-builder' +buildkit_image='m.daocloud.io/docker.io/moby/buildkit:buildx-stable-1' +database_image='mariadb:11.4' +database_mirror_image='m.daocloud.io/docker.io/library/mariadb:11.4' +keep_images=2 +build_cache_max_used_space='24GB' +build_cache_max_age_hours=720 +bootstrap_server=0 +configure_dingtalk_callback=0 +allow_non_main=0 +allow_migrations=0 +app_version='3.1.15' + +while [ "$#" -gt 0 ]; do + case "$1" in + --server) server="$2"; shift ;; + --key) key_path="$2"; shift ;; + --update-password) update_password=1 ;; + --ssh-user) ssh_user="$2"; shift ;; + --remote-path) remote_path="$2"; shift ;; + --app-url) app_url="$2"; shift ;; + --builder) builder_name="$2"; shift ;; + --buildkit-image) buildkit_image="$2"; shift ;; + --database-image) database_image="$2"; shift ;; + --database-mirror-image) database_mirror_image="$2"; shift ;; + --keep-images) keep_images="$2"; shift ;; + --build-cache-max-used-space) build_cache_max_used_space="$2"; shift ;; + --build-cache-max-age-hours) build_cache_max_age_hours="$2"; shift ;; + --app-version) app_version="$2"; shift ;; + --bootstrap-server) bootstrap_server=1 ;; + --configure-dingtalk-callback) configure_dingtalk_callback=1 ;; + --allow-non-main) allow_non_main=1 ;; + --allow-migrations) allow_migrations=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +image_name='caiwu_akaunting' +target_platform='linux/amd64' +image_prefix='127.0.0.1:5000/caiwuxitong/app' +keychain_service='CaiWuXiTong-deploy' +keychain_account="$ssh_user@$server" +ssh_target="$ssh_user@$server" +regctl_version='v0.11.5' + +temp_root='' +tunnel_pid='' +askpass_dir='' +ssh_control_path='' +remote_archive='' +bootstrap_remote='' +ssh_ready=0 +ssh_options=() +tunnel_ssh_options=() + +log() { printf '%s\n' "$*"; } +fail() { printf '%s\n' "$*" >&2; exit 1; } + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +prune_build_cache() { + docker buildx prune --builder "$builder_name" --filter "until=${build_cache_max_age_hours}h" --force >/dev/null 2>&1 || true + local prune_help + prune_help="$(docker buildx prune --help 2>/dev/null || true)" + if printf '%s' "$prune_help" | grep -q -- '--max-used-space'; then + docker buildx prune --builder "$builder_name" --max-used-space "$build_cache_max_used_space" --force >/dev/null 2>&1 || true + elif printf '%s' "$prune_help" | grep -q -- '--keep-storage'; then + docker buildx prune --builder "$builder_name" --keep-storage "$build_cache_max_used_space" --force >/dev/null 2>&1 || true + fi +} + +cleanup() { + local status=$? + set +e + + if [ -n "$remote_archive" ] && [ "$ssh_ready" -eq 1 ]; then + ssh "${ssh_options[@]}" -o ConnectTimeout=5 "$ssh_target" "rm -f '$remote_archive'" >/dev/null 2>&1 + fi + if [ -n "$bootstrap_remote" ] && [ "$ssh_ready" -eq 1 ]; then + ssh "${ssh_options[@]}" -o ConnectTimeout=5 "$ssh_target" "rm -f '$bootstrap_remote'" >/dev/null 2>&1 + fi + if [ -n "$tunnel_pid" ]; then + kill "$tunnel_pid" >/dev/null 2>&1 + fi + if [ -n "$temp_root" ] && [ -d "$temp_root" ]; then + rm -rf "$temp_root" + fi + if [ -n "$askpass_dir" ] && [ -d "$askpass_dir" ]; then + rm -rf "$askpass_dir" + fi + if [ -n "$ssh_control_path" ] && [ "$ssh_ready" -eq 1 ]; then + ssh "${ssh_options[@]}" -O exit "$ssh_target" >/dev/null 2>&1 + rm -f "$ssh_control_path" + fi + + prune_build_cache + docker system df >/dev/null 2>&1 + + exit "$status" +} +trap cleanup EXIT + +# ---- 本机登录信息:私钥 > 已保存的钥匙串密码 > 首次输入 ---- +prepare_ssh_auth() { + local options=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10) + + if [ -n "$key_path" ]; then + [ -f "$key_path" ] || fail "找不到私钥文件:$key_path" + options+=(-i "$key_path" -o BatchMode=yes) + ssh_options=("${options[@]}") + return + fi + + if ssh "${options[@]}" -o BatchMode=yes "$ssh_target" true >/dev/null 2>&1; then + log '使用本机已有的 SSH 密钥登录服务器。' + ssh_options=("${options[@]}") + return + fi + + local password + password="$(security find-generic-password -s "$keychain_service" -a "$keychain_account" -w 2>/dev/null || true)" + + if [ "$update_password" -eq 1 ] || [ -z "$password" ]; then + if [ "$update_password" -eq 1 ]; then + log '更新服务器登录信息。' + else + log '首次部署:请输入服务器密码,之后会自动使用本机钥匙串里保存的登录信息。' + fi + printf '服务器密码:' >&2 + read -rs password + printf '\n' >&2 + [ -n "$password" ] || fail '未输入服务器密码,已停止部署。' + security add-generic-password -U -s "$keychain_service" -a "$keychain_account" -w "$password" >/dev/null + else + log '正在使用本机钥匙串里保存的部署登录信息。' + fi + + askpass_dir="$(mktemp -d)" + local askpass="$askpass_dir/askpass.sh" + printf '%s\n' \ + '#!/bin/sh' \ + 'exec security find-generic-password -s "$CAIWU_DEPLOY_KEYCHAIN_SERVICE" -a "$CAIWU_DEPLOY_KEYCHAIN_ACCOUNT" -w' \ + > "$askpass" + chmod 700 "$askpass" + + export CAIWU_DEPLOY_KEYCHAIN_SERVICE="$keychain_service" + export CAIWU_DEPLOY_KEYCHAIN_ACCOUNT="$keychain_account" + export SSH_ASKPASS="$askpass" + export SSH_ASKPASS_REQUIRE=force + export DISPLAY='caiwu-deploy' + + options+=(-o BatchMode=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) + ssh_options=("${options[@]}") +} + +scp_to_remote() { + if scp -O "${ssh_options[@]}" "$@" 2>/dev/null; then + return 0 + fi + scp "${ssh_options[@]}" "$@" +} + +get_regctl_path() { + local tool_root="$HOME/.cache/CaiWuXiTong/tools" + local asset expected_hash arch path + + case "$(uname -m)" in + arm64) + asset='regctl-darwin-arm64' + expected_hash='f4d536d64d0c3cc1db7400902175a1c314675991d22e87e15c319501a2676d3f' + ;; + x86_64) + asset='regctl-darwin-amd64' + expected_hash='c132fdddda68b9c7584ac19f3b40cd17f71916c2bca8182270ebe65b55198a12' + ;; + *) fail "不支持的 macOS 架构:$(uname -m)" ;; + esac + + path="$tool_root/regctl-$regctl_version" + mkdir -p "$tool_root" + + if [ -f "$path" ] && [ "$(sha256_file "$path")" = "$expected_hash" ]; then + chmod +x "$path" + printf '%s' "$path" + return + fi + + local download="$path.download" + curl -fsSL -o "$download" "https://github.com/regclient/regclient/releases/download/$regctl_version/$asset" + if [ "$(sha256_file "$download")" != "$expected_hash" ]; then + rm -f "$download" + fail 'regctl 校验失败。' + fi + mv "$download" "$path" + chmod +x "$path" + printf '%s' "$path" +} + +pick_free_port() { + if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import socket +s = socket.socket() +s.bind(('127.0.0.1', 0)) +print(s.getsockname()[1]) +s.close() +PY + return + fi + local port + for port in $(jot -r 20 49152 65535 2>/dev/null || seq 49000 49020); do + if ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + printf '%s' "$port" + return + fi + done + fail '找不到可用的本机端口。' +} + +start_registry_tunnel() { + local local_port="$1" + ssh "${tunnel_ssh_options[@]}" \ + -N -o ConnectTimeout=10 -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 -o ServerAliveCountMax=20 \ + -L "127.0.0.1:${local_port}:127.0.0.1:5000" "$ssh_target" & + tunnel_pid=$! + + local attempt=1 + while [ "$attempt" -le 20 ]; do + if ! kill -0 "$tunnel_pid" >/dev/null 2>&1; then + fail 'SSH 增量传输隧道启动失败。' + fi + if curl --fail --silent --show-error --max-time 1 "http://127.0.0.1:${local_port}/v2/" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + attempt=$((attempt + 1)) + done + + fail 'SSH 隧道已建立,但无法访问服务器本机 Registry。' +} + +stop_registry_tunnel() { + if [ -n "$tunnel_pid" ]; then + kill "$tunnel_pid" >/dev/null 2>&1 || true + wait "$tunnel_pid" 2>/dev/null || true + tunnel_pid='' + fi +} + +ensure_qiniu_storage_runtime() { + local remote_environment="$remote_path/runtime/qiniu-storage.env" + + if ssh "${ssh_options[@]}" "$ssh_target" \ + "test -f '$remote_environment' && grep -q '^BUSINESS_STORAGE_DRIVER=qiniu$' '$remote_environment' && grep -q '^QINIU_ACCESS_KEY=.' '$remote_environment' && grep -q '^QINIU_SECRET_KEY=.' '$remote_environment'" >/dev/null 2>&1; then + log '服务器私有业务文件存储配置已存在。' + return + fi + + local access_key="${QINIU_ACCESS_KEY:-}" + local secret_key="${QINIU_SECRET_KEY:-}" + + if [ -z "$access_key" ] || [ -z "$secret_key" ]; then + local credential_file="$repo_root/runtime/qiniu-business-storage.env" + if [ -f "$credential_file" ]; then + access_key="$(grep -E '^[[:space:]]*QINIU_ACCESS_KEY[[:space:]]*=' "$credential_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'\'']//; s/["'\'']$//')" + secret_key="$(grep -E '^[[:space:]]*QINIU_SECRET_KEY[[:space:]]*=' "$credential_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'\'']//; s/["'\'']$//')" + fi + fi + + if [ -z "$access_key" ] || [ -z "$secret_key" ]; then + fail '服务器尚未配置私有业务文件存储,且本机没有可用的七牛凭据。' + fi + + printf 'BUSINESS_STORAGE_DRIVER=qiniu\nQINIU_ACCESS_KEY=%s\nQINIU_SECRET_KEY=%s\n' "$access_key" "$secret_key" \ + | ssh "${ssh_options[@]}" "$ssh_target" \ + "umask 077; cat > '$remote_environment.pending'; chmod 600 '$remote_environment.pending'; mv '$remote_environment.pending' '$remote_environment'" \ + || fail '写入服务器私有业务文件存储配置失败。' + + log '服务器私有业务文件存储配置已安全写入。' +} + +remove_old_project_images() { + local current_image="$1" candidate keep kept + kept="$current_image" + + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + [ "$candidate" = ':' ] && continue + case " $kept " in *" $candidate "*) continue ;; esac + [ "$(printf '%s\n' $kept | wc -l | tr -d ' ')" -ge "$keep_images" ] && break + kept="$kept $candidate" + done </dev/null 2>&1 || true ;; + esac + done </dev/null 2>&1; then + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。' + fi +fi + +deploy_sha="$(git rev-parse HEAD | tr -d '[:space:]')" +image_tag="$(printf '%s' "$deploy_sha" | cut -c1-12)" +full_image="$image_name:$image_tag" +build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if [ -z "$app_url" ]; then + if [ "$server" = '218.93.190.75' ]; then + app_url='https://caiwu.jiansuntuzhi.cn:8442' + else + app_url="https://$server" + fi +fi + +docker version >/dev/null 2>&1 || fail 'Docker Desktop 未启动或不可用。' + +prepare_ssh_auth + +attempt=1 +while [ "$attempt" -le 3 ]; do + if ssh "${ssh_options[@]}" "$ssh_target" true >/dev/null 2>&1; then + ssh_ready=1 + break + fi + if [ "$attempt" -lt 3 ]; then + log "服务器登录失败,正在重试($attempt/3)。" + sleep 3 + fi + attempt=$((attempt + 1)) +done +[ "$ssh_ready" -eq 1 ] || fail '服务器登录失败或已取消,已停止部署。' + +ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock" +ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path") +tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none) + +remote_archive="$remote_path/image-$image_tag.tar.gz" +temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")" +local_tar="$temp_root/image.tar" +local_archive="$temp_root/image.tar.gz" + +log "发布提交:${deploy_sha}(分支 ${branch})" + +bash "$script_dir/build-frontend.sh" + +if ! docker buildx inspect "$builder_name" >/dev/null 2>&1; then + docker buildx create \ + --name "$builder_name" \ + --driver docker-container \ + --driver-opt "image=$buildkit_image" \ + --buildkitd-config "$repo_root/deploy/buildkitd.toml" \ + --use >/dev/null || fail '创建项目专属 BuildKit 构建器失败。' +fi + +docker buildx inspect "$builder_name" --bootstrap >/dev/null || fail '启动 BuildKit 构建器失败。' + +build_image() { + docker buildx build \ + --builder "$builder_name" \ + --platform "$target_platform" \ + --load \ + --tag "$full_image" \ + --build-context 'frontend=.caiwu-build/public' \ + --build-arg "VCS_REF=$deploy_sha" \ + --build-arg "BUILD_DATE=$build_date" \ + --build-arg "APP_VERSION=$app_version" \ + --label 'com.caiwu.project=CaiWuXiTong' \ + --file deploy/Dockerfile \ + . +} + +build_image || fail '本机镜像构建失败。服务器未发生任何变更。' + +docker run --rm --network none --user www-data --entrypoint php "$full_image" artisan --version >/dev/null \ + || fail '发布镜像无法引导 Laravel。服务器未发生任何变更。' + +for pair in \ + "resources/assets/js/components/business/AccountBills.vue:resources/assets/js/components/business/AccountBills.vue" \ + ".caiwu-build/public/js/business/pages.min.js:public/js/business/pages.min.js" \ + ".caiwu-build/public/mix-manifest.json:public/mix-manifest.json"; do + local_file="${pair%%:*}" + image_file="${pair##*:}" + expected_hash="$(sha256_file "$repo_root/$local_file")" + image_hash="$(docker run --rm --network none --user www-data --entrypoint sha256sum "$full_image" "$image_file" | awk '{print $1}')" + if [ "$image_hash" != "$expected_hash" ]; then + fail "发布镜像源码或前端资源不匹配:${image_file}。服务器未发生任何变更。" + fi +done + +previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')" + +if [ -n "$previous_sha" ]; then + if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then + previous_sha_exists=1 + else + previous_sha_exists=0 + fi + + if [ "$previous_sha_exists" -eq 0 ] && [ "$allow_migrations" -eq 0 ]; then + fail "本地缺少服务器当前提交 ${previous_sha},无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 --allow-migrations。" + fi + + if [ "$previous_sha_exists" -eq 1 ]; then + migration_changes="$(git diff --diff-filter=ACMRTUXB --name-only "$previous_sha..$deploy_sha" -- database/migrations ':(glob)modules/*/Database/Migrations/*.php')" + if [ -n "$migration_changes" ] && [ "$allow_migrations" -eq 0 ]; then + fail "检测到数据库迁移:$(printf '%s' "$migration_changes" | tr '\n' ' ')。由 Codex 核对全部待执行迁移;本任务镜像迁移完成备份后可直接添加 --allow-migrations,其他迁移按 AGENTS.md 执行。" + fi + fi +fi + +if [ "$bootstrap_server" -eq 1 ]; then + bootstrap_remote="/tmp/caiwu-bootstrap-$image_tag.sh" + scp_to_remote "$repo_root/deploy/bootstrap-server.sh" "${ssh_target}:$bootstrap_remote" || fail '上传服务器初始化脚本失败。' + ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$bootstrap_remote' && '$bootstrap_remote' '$app_url' '$remote_path' && rm -f '$bootstrap_remote'" \ + || fail '服务器初始化失败。' + bootstrap_remote='' +fi + +ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \ + || fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。' + +ensure_qiniu_storage_runtime + +migration_flag=0 +[ "$allow_migrations" -eq 1 ] && migration_flag=1 + +scp_to_remote \ + "$repo_root/compose.jiangling.yml" \ + "$repo_root/deploy/activate-release.sh" \ + "$repo_root/deploy/activate-incremental-release.sh" \ + "$repo_root/deploy/config-db-init.sh" \ + "$repo_root/deploy/update-runtime-url.sh" \ + "$repo_root/deploy/configure-dingtalk-callback.sh" \ + "$repo_root/deploy/host-nginx-8442-dingtalk.conf" \ + "$repo_root/deploy/setup-incremental-registry.sh" \ + "$repo_root/deploy/registry-config.yml" \ + "${ssh_target}:$remote_path/" || fail '上传运行配置失败。' + +ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \ + || fail '更新服务器访问地址失败。' + +if [ "$bootstrap_server" -eq 1 ]; then + if ! docker image inspect "$database_image" >/dev/null 2>&1; then + if ! docker image inspect "$database_mirror_image" >/dev/null 2>&1; then + docker pull "$database_mirror_image" || fail '下载数据库运行镜像失败。' + fi + docker tag "$database_mirror_image" "$database_image" || fail '标记数据库运行镜像失败。' + fi + + docker save --output "$local_tar" "$full_image" "$database_image" || fail '导出应用与数据库运行镜像失败。' + gzip -1 -c "$local_tar" > "$local_archive" + rm -f "$local_tar" + archive_size="$(du -m "$local_archive" | awk '{print $1}')" + log "首次初始化全量包:${archive_size} MiB" + + scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。' + ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \ + || fail '服务器激活、健康检查或增量仓库初始化失败。' +else + ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \ + "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \ + || fail '服务器增量镜像仓库初始化失败。' + + docker buildx build \ + --builder "$builder_name" \ + --platform "$target_platform" \ + --output "type=oci,dest=$local_tar" \ + --tag "$full_image" \ + --build-context 'frontend=.caiwu-build/public' \ + --build-arg "VCS_REF=$deploy_sha" \ + --build-arg "BUILD_DATE=$build_date" \ + --build-arg "APP_VERSION=$app_version" \ + --label 'com.caiwu.project=CaiWuXiTong' \ + --file deploy/Dockerfile \ + . || fail '导出稳定分层的 OCI 应用镜像失败。' + + regctl_path="$(get_regctl_path)" + local_registry_port="$(pick_free_port)" + start_registry_tunnel "$local_registry_port" + registry_image="127.0.0.1:${local_registry_port}/caiwuxitong/app:$image_tag" + server_registry_image="$image_prefix:$image_tag" + + before_bytes="$(ssh "${ssh_options[@]}" "$ssh_target" "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1" | tr -dc '0-9')" + : "${before_bytes:=0}" + upload_start="$(date +%s)" + upload_ok=0 + upload_attempt=1 + while [ "$upload_attempt" -le 2 ]; do + if [ "$upload_attempt" -gt 1 ]; then + stop_registry_tunnel + local_registry_port="$(pick_free_port)" + start_registry_tunnel "$local_registry_port" + registry_image="127.0.0.1:${local_registry_port}/caiwuxitong/app:$image_tag" + fi + + if "$regctl_path" --host "reg=127.0.0.1:${local_registry_port},tls=disabled" image import "$registry_image" "$local_tar"; then + upload_ok=1 + break + fi + + if [ "$upload_attempt" -eq 1 ]; then + log '增量镜像隧道在上传期间中断,正在重新建立隧道并重试一次。' + fi + upload_attempt=$((upload_attempt + 1)) + done + [ "$upload_ok" -eq 1 ] || fail '增量镜像层上传失败。' + + upload_seconds=$(( $(date +%s) - upload_start )) + after_bytes="$(ssh "${ssh_options[@]}" "$ssh_target" "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1" | tr -dc '0-9')" + : "${after_bytes:=0}" + incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')" + log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒" + + ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \ + || fail '服务器增量激活或健康检查失败。' +fi + +expected_page_hash="$(sha256_file "$repo_root/.caiwu-build/public/js/business/pages.min.js")" +deployed_page_hash="$(ssh "${ssh_options[@]}" "$ssh_target" "docker exec caiwu_app sha256sum /var/www/html/public/js/business/pages.min.js | cut -d ' ' -f1" | tr -d '[:space:]')" +if [ "$deployed_page_hash" != "$expected_page_hash" ]; then + fail '生产容器中的业务页签脚本与本次构建不一致。' +fi + +if [ "$configure_dingtalk_callback" -eq 1 ]; then + ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/configure-dingtalk-callback.sh' && '$remote_path/configure-dingtalk-callback.sh' '$server' '$remote_path'" \ + || fail '钉钉 HTTP 回调 Nginx 配置失败。' +fi + +remove_old_project_images "$full_image" +log "发布完成:$full_image -> $app_url"