From c4acb91c27e6d182bdfe7f62fa04a3e9b5bd02df Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 5 Nov 2026 11:54:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=89=93=E5=8C=85=E4=B8=8E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=9C=8DDocker=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/.dockerignore | 8 +- web/.gitattributes | 4 + web/.gitignore | 1 + web/README.md | 10 +++ web/deploy/Dockerfile | 21 +++++ web/deploy/README.md | 99 ++++++++++++++++++++++ web/deploy/build-docker.mjs | 11 +++ web/deploy/build-package.mjs | 7 ++ web/deploy/deploy-test.mjs | 59 +++++++++++++ web/deploy/deploy.sh | 117 ++++++++++++++++++++++++++ web/deploy/deploy.test.sh | 96 +++++++++++++++++++++ web/deploy/nginx.conf | 54 ++++++++++++ web/deploy/release.mjs | 152 ++++++++++++++++++++++++++++++++++ web/deploy/release.test.mjs | 52 ++++++++++++ web/deploy/runtime.Dockerfile | 8 ++ web/package.json | 4 + 16 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 web/.gitattributes create mode 100644 web/deploy/Dockerfile create mode 100644 web/deploy/README.md create mode 100644 web/deploy/build-docker.mjs create mode 100644 web/deploy/build-package.mjs create mode 100644 web/deploy/deploy-test.mjs create mode 100644 web/deploy/deploy.sh create mode 100644 web/deploy/deploy.test.sh create mode 100644 web/deploy/nginx.conf create mode 100644 web/deploy/release.mjs create mode 100644 web/deploy/release.test.mjs create mode 100644 web/deploy/runtime.Dockerfile diff --git a/web/.dockerignore b/web/.dockerignore index 40b878d..215d293 100644 --- a/web/.dockerignore +++ b/web/.dockerignore @@ -1 +1,7 @@ -node_modules/ \ No newline at end of file +node_modules/ +.git/ +.cache/ +*.log +config.yaml +.env.*.local +.codegraph/ diff --git a/web/.gitattributes b/web/.gitattributes new file mode 100644 index 0000000..9f7bd71 --- /dev/null +++ b/web/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +*.mjs text eol=lf +*.conf text eol=lf +*Dockerfile text eol=lf diff --git a/web/.gitignore b/web/.gitignore index 0eaf983..72f7598 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,5 +1,6 @@ /node_modules/ /dist/ +/.cache/ package-lock.json yarn.lock bun.lockb diff --git a/web/README.md b/web/README.md index c30e71e..9a4af0d 100644 --- a/web/README.md +++ b/web/README.md @@ -16,3 +16,13 @@ pnpm build ``` 开发环境沿用当前分支配置,将 `/api` 代理到 `http://sw2.fuhua91.com/api`。生产构建使用同域 `/api`;本地独立后端默认监听 `8000`,切换本地调试时需同步调整 `.env.development` 与 Vite 代理的前缀处理。 + +前端可独立打包和发布到 sw2 测试服: + +```sh +pnpm package:test # 构建并生成带 SHA-256 的前端发布包 +pnpm build:docker # 在 Docker 中从前端源码构建镜像 +pnpm deploy:test # 打包并只更新测试服前端 +``` + +复用发布包、仅预检、SSH 参数及回退方法见 [前端独立部署说明](deploy/README.md)。 diff --git a/web/deploy/Dockerfile b/web/deploy/Dockerfile new file mode 100644 index 0000000..37e5edf --- /dev/null +++ b/web/deploy/Dockerfile @@ -0,0 +1,21 @@ +# Full frontend source build. Compatible with the test server's Docker 18.06. +FROM docker.m.daocloud.io/node:22-slim AS builder +ARG PNPM_VERSION=10.15.1 +WORKDIR /src +RUN npm install --global pnpm@${PNPM_VERSION} +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build +ARG VERSION=local +ARG SOURCE_REVISION=unknown +ARG SOURCE_TREE=unknown +RUN node --input-type=module -e 'import fs from "node:fs"; const info = { formatVersion: 1, version: process.env.VERSION, revision: process.env.SOURCE_REVISION, tree: process.env.SOURCE_TREE, builtAt: new Date().toISOString() }; fs.writeFileSync("dist/build-info.json", JSON.stringify(info)); fs.writeFileSync("dist/version.txt", info.version + "\n");' + +FROM docker.m.daocloud.io/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236 +ARG VERSION=local +LABEL org.opencontainers.image.revision=$VERSION +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /src/dist/ /usr/share/nginx/html/ +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 diff --git a/web/deploy/README.md b/web/deploy/README.md new file mode 100644 index 0000000..c4b96ae --- /dev/null +++ b/web/deploy/README.md @@ -0,0 +1,99 @@ +# 前端独立构建与测试服发布 + +在前端 `game-busAdminSystem` 仓库的 `kra` 分支根目录执行。全部命令只需要前端源码,不需要 Go 仓库或后端配置。 + +本机需要 Node.js 22.12 及以上、pnpm 10 及以上、Git。生成发布包还需 `tar`;发布需 OpenSSH 的 `ssh` / `scp`。Windows、macOS、Linux 使用相同的 pnpm 命令。Docker 源码构建另需本机 Docker;发布包方式在服务器构建镜像,本机不需要 Docker。 + +## 1. 只构建静态文件 + +```sh +pnpm install --frozen-lockfile +pnpm build +``` + +产物是 `dist/`。生产接口使用同域 `/api`,前端包里不写数据库密码、SDK 密钥或 SSH 密码。 + +## 2. 独立打包 + +先提交前端改动;脚本会检查前端目录的已修改、暂存及未跟踪文件,构建过程中生成的 `src/pathInfo.json` 也须与提交一致。 + +```sh +pnpm package:test +# 自定义版本;默认版本为当前提交号前 12 位 +pnpm package:test --tag web-20261105-01 +``` + +脚本安装锁定依赖、执行生产构建,生成: + +```text +.cache/deploy/sw2-frontend-<版本>.tar.gz +.cache/deploy/sw2-frontend-<版本>.tar.gz.sha256 +``` + +包内只有 `dist/`、运行镜像的 `Dockerfile`、`nginx.conf`、服务端 `deploy.sh` 与 `release.json`。版本清单记录提交号、源码树和构建时间,静态目录中的 `build-info.json` / `version.txt` 用于发布核对。已有同名包不会被覆盖;发布时复用该包或指定新版本。 + +`--skip-install` 可在已经按锁文件安装依赖后使用;`--output <目录>` 可指定包输出目录。在 `sw-new/web` 中开发时也可运行:检查只覆盖前端,后端本地配置不影响打包。若已在本地生成分仓提交,可传 `--source-ref codex/publish-web`,脚本会验证该提交的根目录与当前前端源码树一致。 + +## 3. 从前端源码构建 Docker 镜像 + +```sh +pnpm build:docker +pnpm build:docker --tag web-20261105-01 +``` + +生成 `sw2/frontend:<版本>`。`deploy/Dockerfile` 使用多阶段构建,在 Node 容器安装依赖并构建,再将产物放入 Nginx 镜像。`deploy/runtime.Dockerfile` 用于上面的发布包,服务器无需安装 Node。 + +仓库根目录原有 `Dockerfile` 仍供 Kubernetes/Jenkins 使用,继续读取 `nginx.k8s.conf`;独立测试服发布使用 `deploy/` 下的 Dockerfile。 + +## 4. 发布到 sw2 测试服 + +默认连接 `root@192.168.2.226:22`,部署根目录 `/www/wwwroot/sw2.fuhua91.com`。使用本机 SSH 密钥或 SSH agent;首次使用先用 `ssh root@192.168.2.226` 确认主机指纹和登录权限。脚本使用非交互 SSH,仓库不保存密码。 + +```sh +# 构建、上传、校验 SHA-256 并发布 +pnpm deploy:test + +# 复用已经生成的包 +pnpm deploy:test --archive .cache/deploy/sw2-frontend-web-20261105-01.tar.gz + +# 先在服务器构建镜像、验证页面版本和 /api/health,保持当前容器运行 +pnpm deploy:test --archive .cache/deploy/sw2-frontend-web-20261105-01.tar.gz --build-only + +# 使用其他 SSH 账号、端口或密钥 +pnpm deploy:test --archive <发布包.tar.gz> --user deploy --port 22 --identity <私钥路径> +``` + +可用 `--host` 与 `--directory` 覆盖测试服务器和部署根目录。服务器账号需要该目录写权限及 Docker 权限。现有的 `sw2-net` 网络、`sw2-backend:8000` 服务与宝塔反向代理应已就绪;脚本兼容测试服 Docker 18.06,不依赖 Docker Compose。 + +发布过程: + +1. 验证包结构和版本,上传至 `frontend-releases/`,校验 SHA-256。相同版本只接受同一个发布包。 +2. 构建镜像,运行临时前端容器,检查 `/healthz`、`/version.txt` 和 `/api/health`。 +3. 预检通过后,只替换 `sw2-frontend`,仍绑定 `127.0.0.1:9601`。上一版保留为停止状态的 `sw2-frontend-previous`。 +4. 再次检查新容器;失败自动恢复上一版。成功后写入 `current-frontend-release`,后端容器、数据库、后端配置及联合发布的 `current-release` 均不修改。 + +Nginx 使用 Docker DNS 动态解析后端地址,后端独立重建后无需重建前端镜像。对外仍访问 `http://sw2.fuhua91.com`。 + +## 回退与验证 + +重新发布保留的旧前端包即可回退,仍只操作前端: + +```sh +pnpm deploy:test --archive .cache/deploy/sw2-frontend-<旧版本>.tar.gz +``` + +服务端手工执行同样的脚本: + +```sh +bash /www/wwwroot/sw2.fuhua91.com/frontend-releases/<版本>/deploy.sh <版本> +curl -fsS http://127.0.0.1:9601/version.txt +curl -fsS http://127.0.0.1:9601/api/health +``` + +发布脚本通过文件锁阻止并发前端发布。永久回归测试覆盖包路径与版本验证、预检失败、发布后失败回退、只构建模式,以及后端容器保持不变: + +```sh +pnpm test:release +``` + +测试需要 Bash;Windows 可使用 Git for Windows 自带的 Bash,或通过 `BASH` 环境变量指定。 diff --git a/web/deploy/build-docker.mjs b/web/deploy/build-docker.mjs new file mode 100644 index 0000000..f306e2c --- /dev/null +++ b/web/deploy/build-docker.mjs @@ -0,0 +1,11 @@ +import { options, sourceInfo, run, fail } from './release.mjs' + +try { + const args = options({ tag: { type: 'string' }, 'source-ref': { type: 'string' } }) + if (args.help) console.log('pnpm build:docker [--tag VERSION] [--source-ref FRONTEND_COMMIT]') + else { + const info = sourceInfo(args) + run('docker', ['build', '-f', 'deploy/Dockerfile', '--build-arg', `VERSION=${info.version}`, '--build-arg', `SOURCE_REVISION=${info.revision}`, '--build-arg', `SOURCE_TREE=${info.tree}`, '-t', `sw2/frontend:${info.version}`, '.']) + console.log(`镜像:sw2/frontend:${info.version}`) + } +} catch (error) { fail(error) } diff --git a/web/deploy/build-package.mjs b/web/deploy/build-package.mjs new file mode 100644 index 0000000..e2c396f --- /dev/null +++ b/web/deploy/build-package.mjs @@ -0,0 +1,7 @@ +import { options, packageRelease, fail } from './release.mjs' + +try { + const args = options({ tag: { type: 'string' }, 'source-ref': { type: 'string' }, output: { type: 'string' }, 'skip-install': { type: 'boolean' } }) + if (args.help) console.log('pnpm package:test [--tag VERSION] [--output DIRECTORY] [--skip-install] [--source-ref FRONTEND_COMMIT]') + else packageRelease(args) +} catch (error) { fail(error) } diff --git a/web/deploy/deploy-test.mjs b/web/deploy/deploy-test.mjs new file mode 100644 index 0000000..47bb29a --- /dev/null +++ b/web/deploy/deploy-test.mjs @@ -0,0 +1,59 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { options, packageRelease, inspectPackage, deploymentTarget, shellQuote, run, fail } from './release.mjs' + +export const installScript = `set -euo pipefail +BASE_DIR=$(cd -- "$1" && pwd -P) +ARCHIVE=$2 +EXPECTED=$3 +TAG=$4 +MODE=$5 +RELEASE="$BASE_DIR/frontend-releases/$TAG" +STAGING='' +cleanup() { + rm -f -- "$ARCHIVE" + if [[ -n "$STAGING" && "$STAGING" == "$BASE_DIR/frontend-releases/.incoming-"* ]]; then rm -rf -- "$STAGING"; fi +} +trap cleanup EXIT +exec 8>"$BASE_DIR/frontend-releases/.install-$TAG.lock" +flock -x 8 +printf '%s %s\\n' "$EXPECTED" "$ARCHIVE" | sha256sum -c - +if [[ -e "$RELEASE" ]]; then + [[ -f "$RELEASE/package.sha256" && "$(cat "$RELEASE/package.sha256")" == "$EXPECTED" ]] || { echo 'Existing release has a different checksum' >&2; exit 1; } +else + STAGING=$(mktemp -d "$BASE_DIR/frontend-releases/.incoming-$TAG.XXXXXX") + tar --no-same-owner -xzf "$ARCHIVE" -C "$STAGING" + printf '%s\\n' "$EXPECTED" > "$STAGING/package.sha256" + mv -T -- "$STAGING" "$RELEASE" + STAGING='' +fi +SW_DEPLOY_BASE="$BASE_DIR" bash "$RELEASE/deploy.sh" "$TAG" "$MODE" +` + +function main() { + const args = options({ + archive: { type: 'string' }, tag: { type: 'string' }, 'source-ref': { type: 'string' }, 'skip-install': { type: 'boolean' }, + host: { type: 'string' }, user: { type: 'string' }, port: { type: 'string' }, identity: { type: 'string' }, directory: { type: 'string' }, 'build-only': { type: 'boolean' } + }) + if (args.help) { + console.log('pnpm deploy:test [--archive FILE.tar.gz] [--tag VERSION] [--build-only] [--host 192.168.2.226] [--user root] [--port 22] [--identity SSH_KEY] [--directory /www/wwwroot/sw2.fuhua91.com]\n不指定 --archive 时先独立构建前端;--build-only 仅在服务器构建并预检,不替换当前容器。') + return + } + if (args.archive && (args.tag || args['source-ref'] || args['skip-install'])) throw new Error('--archive 复用现有包,不能再指定构建参数') + const target = deploymentTarget(args) + const release = args.archive ? inspectPackage(args.archive) : inspectPackage(packageRelease(args).archive) + const identity = args.identity ? ['-i', path.resolve(args.identity)] : [] + const ssh = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-p', target.port, ...identity, target.remote] + const uploads = `${target.base}/frontend-releases` + const uploaded = `${uploads}/.upload-${release.info.version}-${crypto.randomBytes(6).toString('hex')}.tar.gz` + run('ssh', [...ssh, `test -d ${shellQuote(target.base)} && test -w ${shellQuote(target.base)} && mkdir -p ${shellQuote(uploads)}`]) + run('scp', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-P', target.port, ...identity, release.archive, `${target.remote}:${uploaded}`]) + const parameters = [target.base, uploaded, release.checksum, release.info.version, args['build-only'] ? '--build-only' : ''] + run('ssh', [...ssh, `bash -s -- ${parameters.map(shellQuote).join(' ')}`], { stdio: ['pipe', 'inherit', 'inherit'], input: installScript }) + console.log(args['build-only'] ? `预检通过:${release.info.version},当前前端未替换` : `前端发布完成:${release.info.version};http://sw2.fuhua91.com`) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main() } catch (error) { fail(error) } +} diff --git a/web/deploy/deploy.sh b/web/deploy/deploy.sh new file mode 100644 index 0000000..a55519b --- /dev/null +++ b/web/deploy/deploy.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 027 + +TAG="${1:?Usage: deploy.sh VERSION [--build-only]}" +MODE="${2:-}" +[[ "$TAG" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$ ]] || { echo 'Invalid version' >&2; exit 1; } +[[ -z "$MODE" || "$MODE" == '--build-only' ]] || { echo 'Invalid deployment mode' >&2; exit 1; } +BASE_DIR="${SW_DEPLOY_BASE:-/www/wwwroot/sw2.fuhua91.com}" +BASE_DIR=$(cd -- "$BASE_DIR" && pwd -P) +RELEASE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +[[ "$RELEASE" == "$BASE_DIR/frontend-releases/$TAG" ]] || { echo 'Release directory does not match version' >&2; exit 1; } +NAME=sw2-frontend +PREVIOUS=sw2-frontend-previous +CANDIDATE="sw2-frontend-candidate-$$" +IMAGE="sw2/frontend:$TAG" +NETWORK=sw2-net +ATTEMPTS="${SW_HEALTH_ATTEMPTS:-20}" +[[ "$ATTEMPTS" =~ ^[1-9][0-9]?$ ]] || { echo 'Invalid health-check attempts' >&2; exit 1; } + +for tool in docker curl flock; do command -v "$tool" >/dev/null; done +exec 9>"$BASE_DIR/.frontend-deploy.lock" +flock -n 9 || { echo 'Another frontend deployment is running' >&2; exit 1; } + +exists() { docker inspect "$1" >/dev/null 2>&1; } +managed() { [[ "$(docker inspect --format '{{index .Config.Labels "com.sw2.application"}}' "$1")" == sw2 ]]; } +for container in "$NAME" "$PREVIOUS"; do + if exists "$container"; then + managed "$container" || { echo "Refusing to replace unmanaged container: $container" >&2; exit 1; } + fi +done +if exists "$PREVIOUS"; then + [[ "$(docker inspect --format '{{.State.Running}}' "$PREVIOUS")" == false ]] || { echo 'Previous frontend container is still running' >&2; exit 1; } +fi +! exists "$CANDIDATE" || { echo 'Candidate container already exists' >&2; exit 1; } +docker network inspect "$NETWORK" >/dev/null +test -s "$RELEASE/dist/index.html" +[[ "$(cat "$RELEASE/dist/version.txt")" == "$TAG" ]] || { echo 'Frontend version marker mismatch' >&2; exit 1; } + +old_present=false +old_stopped=false +moved=false +created=false +committed=false +old_version='' +if exists "$NAME"; then + old_present=true + old_version=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$NAME") +fi + +finish() { + result=$? + trap - EXIT INT TERM + set +e + if [[ "$result" -ne 0 && "$committed" == false ]]; then + if [[ "$created" == true ]] && exists "$NAME" && managed "$NAME"; then docker rm -f "$NAME" >/dev/null; fi + if [[ "$moved" == true ]]; then + docker rename "$PREVIOUS" "$NAME" + docker start "$NAME" >/dev/null + echo 'Frontend deployment failed; previous frontend restored.' >&2 + elif [[ "$old_stopped" == true ]] && exists "$NAME"; then + docker start "$NAME" >/dev/null + fi + fi + if exists "$CANDIDATE"; then docker rm -f "$CANDIDATE" >/dev/null; fi + exit "$result" +} +trap finish EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +healthy() { + local address=$1 + local attempt + for ((attempt=1; attempt<=ATTEMPTS; attempt++)); do + if [[ "$(curl -fsS --max-time 3 "$address/healthz" 2>/dev/null)" == ok ]] && + [[ "$(curl -fsS --max-time 3 "$address/version.txt" 2>/dev/null)" == "$TAG" ]] && + curl -fsS --max-time 5 "$address/api/health" >/dev/null 2>&1; then return 0; fi + if ((attempt < ATTEMPTS)); then sleep 1; fi + done + return 1 +} + +docker build --build-arg "VERSION=$TAG" -t "$IMAGE" "$RELEASE" +docker run --rm "$IMAGE" nginx -t +docker run -d --name "$CANDIDATE" --label com.sw2.application=sw2 --label com.sw2.component=frontend \ + --network "$NETWORK" -p 127.0.0.1::80 "$IMAGE" >/dev/null +candidate_address=$(docker port "$CANDIDATE" 80/tcp) +[[ "$candidate_address" =~ ^127\.0\.0\.1:[0-9]+$ ]] || { echo 'Unexpected candidate binding' >&2; exit 1; } +healthy "http://$candidate_address" || { echo 'Candidate frontend or API proxy failed preflight' >&2; exit 1; } +if [[ "$MODE" == '--build-only' ]]; then + echo "Frontend image and API proxy preflight passed: $TAG" + exit 0 +fi + +if exists "$PREVIOUS"; then docker rm "$PREVIOUS" >/dev/null; fi +if [[ "$old_present" == true ]]; then + old_stopped=true + docker stop -t 15 "$NAME" >/dev/null + docker rename "$NAME" "$PREVIOUS" + moved=true +fi +created=true +docker run -d --name "$NAME" --label com.sw2.application=sw2 --label com.sw2.component=frontend \ + --restart unless-stopped --network "$NETWORK" \ + --log-opt max-size=20m --log-opt max-file=3 \ + -p 127.0.0.1:9601:80 "$IMAGE" >/dev/null +healthy http://127.0.0.1:9601 || { echo 'Published frontend failed health checks' >&2; exit 1; } + +if [[ -n "$old_version" ]]; then + printf '%s\n' "$old_version" > "$BASE_DIR/previous-frontend-release.tmp" + mv -f -- "$BASE_DIR/previous-frontend-release.tmp" "$BASE_DIR/previous-frontend-release" +fi +printf '%s\n' "$TAG" > "$BASE_DIR/current-frontend-release.tmp" +mv -f -- "$BASE_DIR/current-frontend-release.tmp" "$BASE_DIR/current-frontend-release" +committed=true +printf 'Frontend deployed: %s\nBackend container was not modified.\n' "$TAG" diff --git a/web/deploy/deploy.test.sh b/web/deploy/deploy.test.sh new file mode 100644 index 0000000..7e36e72 --- /dev/null +++ b/web/deploy/deploy.test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/deploy.sh +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/sw-frontend-deploy-test.XXXXXX") +cleanup() { + [[ "$TEST_ROOT" == "${TMPDIR:-/tmp}/sw-frontend-deploy-test."* ]] || exit 1 + rm -rf -- "$TEST_ROOT" +} +trap cleanup EXIT +mkdir "$TEST_ROOT/bin" +cat > "$TEST_ROOT/bin/docker" <<'DOCKER' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$TEST_STATE/calls" +command=$1; shift +case "$command" in + inspect) + format='' + if [[ "$1" == --format ]]; then format=$2; shift 2; fi + [[ -d "$TEST_STATE/$1" ]] || exit 1 + case "$format" in + *com.sw2.application*) cat "$TEST_STATE/$1/app" ;; + *org.opencontainers.image.revision*) cat "$TEST_STATE/$1/version" ;; + *State.Running*) cat "$TEST_STATE/$1/running" ;; + esac ;; + network|build) exit 0 ;; + run) + name=''; version='new' + while (($#)); do + if [[ "$1" == --name ]]; then name=$2; shift 2; else shift; fi + done + [[ -n "$name" ]] || exit 0 + mkdir "$TEST_STATE/$name" + printf 'sw2' > "$TEST_STATE/$name/app" + printf '%s' "$version" > "$TEST_STATE/$name/version" + printf 'true' > "$TEST_STATE/$name/running" + printf 'new-container' > "$TEST_STATE/$name/id" ;; + port) printf '127.0.0.1:19603\n' ;; + stop) + name="${!#}"; printf 'false' > "$TEST_STATE/$name/running" ;; + start) printf 'true' > "$TEST_STATE/$1/running" ;; + rename) mv -- "$TEST_STATE/$1" "$TEST_STATE/$2" ;; + rm) + name="${!#}" + [[ "$name" == sw2-frontend* ]] || exit 1 + rm -rf -- "$TEST_STATE/$name" ;; + *) echo "Unexpected docker command: $command" >&2; exit 1 ;; +esac +DOCKER +cat > "$TEST_ROOT/bin/curl" <<'CURL' +#!/usr/bin/env bash +set -euo pipefail +url="${!#}" +if [[ "$TEST_MODE" == preflight-fail && "$url" == *:19603/* ]]; then exit 1; fi +if [[ "$TEST_MODE" == final-fail && "$url" == *:9601/* ]]; then exit 1; fi +if [[ "$url" == */version.txt ]]; then printf 'new'; else printf 'ok'; fi +CURL +printf '#!/usr/bin/env bash\nexit 0\n' > "$TEST_ROOT/bin/flock" +chmod +x "$TEST_ROOT/bin/"* +export PATH="$TEST_ROOT/bin:$PATH" +export SW_HEALTH_ATTEMPTS=1 + +for scenario in success preflight-fail final-fail build-only unmanaged; do + export TEST_STATE="$TEST_ROOT/$scenario" + export TEST_MODE="$scenario" + export SW_DEPLOY_BASE="$TEST_STATE/base" + mkdir -p "$SW_DEPLOY_BASE/frontend-releases/new/dist" "$TEST_STATE/sw2-frontend" "$TEST_STATE/sw2-backend" + cp "$SCRIPT" "$SW_DEPLOY_BASE/frontend-releases/new/deploy.sh" + printf 'test' > "$SW_DEPLOY_BASE/frontend-releases/new/dist/index.html" + printf 'new\n' > "$SW_DEPLOY_BASE/frontend-releases/new/dist/version.txt" + printf 'old\n' > "$SW_DEPLOY_BASE/current-frontend-release" + printf 'sw2' > "$TEST_STATE/sw2-frontend/app" + printf 'old' > "$TEST_STATE/sw2-frontend/version" + printf 'true' > "$TEST_STATE/sw2-frontend/running" + printf 'old-container' > "$TEST_STATE/sw2-frontend/id" + printf 'backend-unchanged' > "$TEST_STATE/sw2-backend/id" + if [[ "$scenario" == unmanaged ]]; then printf 'other' > "$TEST_STATE/sw2-frontend/app"; fi + mode=''; [[ "$scenario" != build-only ]] || mode='--build-only' + result=0 + bash "$SW_DEPLOY_BASE/frontend-releases/new/deploy.sh" new "$mode" > "$TEST_STATE/output" 2>&1 || result=$? + case "$scenario" in + success) + [[ "$result" == 0 && "$(cat "$TEST_STATE/sw2-frontend/id")" == new-container && "$(cat "$TEST_STATE/sw2-frontend-previous/id")" == old-container ]] + [[ "$(cat "$SW_DEPLOY_BASE/current-frontend-release")" == new ]] ;; + build-only) + [[ "$result" == 0 && "$(cat "$TEST_STATE/sw2-frontend/id")" == old-container ]] + [[ "$(cat "$SW_DEPLOY_BASE/current-frontend-release")" == old ]] ;; + *) + [[ "$result" != 0 && "$(cat "$TEST_STATE/sw2-frontend/id")" == old-container && "$(cat "$TEST_STATE/sw2-frontend/running")" == true ]] + [[ "$(cat "$SW_DEPLOY_BASE/current-frontend-release")" == old ]] ;; + esac + [[ "$(cat "$TEST_STATE/sw2-backend/id")" == backend-unchanged ]] + ! grep -E '(stop|start|rename|rm|run).*sw2-backend' "$TEST_STATE/calls" + [[ "$(find "$TEST_STATE" -maxdepth 1 -name 'sw2-frontend-candidate-*' | wc -l)" == 0 ]] +done +echo 'all deployment scenarios passed' diff --git a/web/deploy/nginx.conf b/web/deploy/nginx.conf new file mode 100644 index 0000000..fd3082f --- /dev/null +++ b/web/deploy/nginx.conf @@ -0,0 +1,54 @@ +map $http_upgrade $sw2_connection_upgrade { + default upgrade; + '' close; +} + +map $http_x_forwarded_proto $sw2_forwarded_proto { + default $scheme; + http http; + https https; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + client_max_body_size 1024m; + + # Re-resolve Docker DNS when the backend is deployed independently. + resolver 127.0.0.11 valid=10s ipv6=off; + set $sw2_backend sw2-backend:8000; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 'ok'; + } + + location = /api { return 308 /api/; } + + location ^~ /api/ { + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://$sw2_backend; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_x_real_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $sw2_forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $sw2_connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location ~ /\. { return 404; } + + location / { + add_header Cache-Control 'no-cache'; + try_files $uri $uri/ /index.html; + } +} diff --git a/web/deploy/release.mjs b/web/deploy/release.mjs new file mode 100644 index 0000000..3ce6543 --- /dev/null +++ b/web/deploy/release.mjs @@ -0,0 +1,152 @@ +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { execFileSync, spawnSync } from 'node:child_process' +import { parseArgs } from 'node:util' + +export const frontendRoot = fileURLToPath(new URL('../', import.meta.url)) +export const options = (definitions) => parseArgs({ + options: { help: { type: 'boolean' }, ...definitions }, + args: process.argv.slice(2).filter((arg, i) => !(i === 0 && arg === '--')) +}).values + +export function run(command, args, settings = {}) { + return execFileSync(command, args, { cwd: frontendRoot, stdio: 'inherit', ...settings }) +} +const capture = (command, args, settings = {}) => run(command, args, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...settings }).trim() +const git = (...args) => capture('git', args) + +export function validTag(value) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(value || '')) throw new Error('版本号只能包含字母、数字、点、下划线和短横线,最长 80 个字符') + return value +} +export const shellQuote = value => `'${String(value).replaceAll("'", "'\\''")}'` + +export function assertClean() { + for (const args of [['diff', '--quiet', '--', '.'], ['diff', '--cached', '--quiet', '--', '.']]) { + const result = spawnSync('git', args, { cwd: frontendRoot, stdio: 'pipe' }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error('请先提交前端源代码及生成的 src/pathInfo.json,再打包') + } + if (git('ls-files', '--others', '--exclude-standard', '--', '.')) throw new Error('前端目录存在未提交文件,请提交或配置忽略规则后再打包') +} + +export function sourceInfo(args = {}) { + assertClean() + const prefix = git('rev-parse', '--show-prefix').replace(/\/$/, '') + const tree = git('rev-parse', prefix ? `HEAD:${prefix}` : 'HEAD^{tree}') + const revision = git('rev-parse', '--verify', '--end-of-options', `${args['source-ref'] || 'HEAD'}^{commit}`) + if (args['source-ref'] && git('rev-parse', `${revision}^{tree}`) !== tree) throw new Error('--source-ref 的根目录必须与当前前端源码树一致') + return { formatVersion: 1, version: validTag(args.tag || revision.slice(0, 12)), revision, tree, sourceSubdirectory: args['source-ref'] ? '' : prefix, builtAt: new Date().toISOString() } +} + +function pnpm(args) { + const executable = process.env.npm_execpath + if (executable && /\.(?:c?js|mjs)$/.test(executable)) return run(process.execPath, [executable, ...args]) + if (process.platform !== 'win32') return run('pnpm', args) + // Only these fixed commands enter cmd.exe; no user argument is interpolated. + const commands = { build: 'pnpm build', install: 'pnpm install --frozen-lockfile' } + return run(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commands[args[0]]]) +} + +function regularTree(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name) + if (entry.isDirectory()) regularTree(filename) + else if (!entry.isFile()) throw new Error(`构建产物不允许符号链接或特殊文件:${filename}`) + } +} + +export function writeBuildInfo(directory, info) { + fs.writeFileSync(path.join(directory, 'build-info.json'), JSON.stringify(info, null, 2) + '\n') + fs.writeFileSync(path.join(directory, 'version.txt'), info.version + '\n') +} + +export function packageRelease(args = {}) { + const info = sourceInfo(args) + const output = path.resolve(frontendRoot, args.output || '.cache/deploy') + fs.mkdirSync(output, { recursive: true }) + const archive = path.join(output, `sw2-frontend-${info.version}.tar.gz`) + if (fs.existsSync(archive)) throw new Error(`发布包已存在,请复用该包或指定新的 --tag:${archive}`) + const lock = `${archive}.lock` + const descriptor = fs.openSync(lock, 'wx') + fs.closeSync(descriptor) + let stage, created = false, complete = false + try { + if (!args['skip-install']) pnpm(['install', '--frozen-lockfile']) + pnpm(['build']) + const after = sourceInfo(args) + if (after.tree !== info.tree || after.revision !== info.revision) throw new Error('构建期间源码发生变化,请重新打包') + const dist = path.join(frontendRoot, 'dist') + if (!fs.statSync(path.join(dist, 'index.html')).isFile()) throw new Error('缺少 dist/index.html') + regularTree(dist) + stage = fs.mkdtempSync(path.join(output, '.frontend-stage-')) + fs.cpSync(dist, path.join(stage, 'dist'), { recursive: true }) + writeBuildInfo(path.join(stage, 'dist'), info) + for (const [from, to] of [['runtime.Dockerfile', 'Dockerfile'], ['nginx.conf', 'nginx.conf'], ['deploy.sh', 'deploy.sh']]) { + fs.writeFileSync(path.join(stage, to), fs.readFileSync(path.join(frontendRoot, 'deploy', from), 'utf8').replaceAll('\r\n', '\n')) + } + fs.writeFileSync(path.join(stage, 'release.json'), JSON.stringify(info, null, 2) + '\n') + const fd = fs.openSync(archive, 'wx'); fs.closeSync(fd); created = true + run('tar', ['-czf', archive, '-C', stage, '.']) + const checksum = sha256(archive) + fs.writeFileSync(`${archive}.sha256`, `${checksum} ${path.basename(archive)}\n`) + complete = true + console.log(`发布包:${archive}\nSHA256:${checksum}`) + return { archive, checksum, info } + } finally { + if (stage && path.dirname(stage) === output && path.basename(stage).startsWith('.frontend-stage-')) fs.rmSync(stage, { recursive: true }) + if (created && !complete) fs.rmSync(archive) + fs.rmSync(lock) + } +} + +export const sha256 = filename => crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex') + +export function validateEntries(names, types) { + for (const name of names) { + const clean = name.replace(/^\.\//, '').replace(/\/$/, '') + if (clean === '' || clean === '.') continue + if (name.includes('\\') || name.includes('\r') || name.startsWith('/') || clean.split('/').includes('..')) throw new Error(`不安全的包内路径:${name}`) + if (!['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist'].includes(clean) && !clean.startsWith('dist/')) throw new Error(`发布包包含非前端产物:${name}`) + } + if (types.some(type => type !== '-' && type !== 'd')) throw new Error('发布包不能包含符号链接、硬链接或特殊文件') + for (const required of ['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist/index.html', 'dist/version.txt']) { + if (!names.some(name => name.replace(/^\.\//, '') === required)) throw new Error(`发布包缺少 ${required}`) + } +} + +export function inspectPackage(filename) { + const archive = path.resolve(filename) + const names = capture('tar', ['-tzf', archive]).split(/\r?\n/) + const types = capture('tar', ['-tvzf', archive]).split(/\r?\n/).map(line => line[0]) + validateEntries(names, types) + const entry = name => names.find(item => item.replace(/^\.\//, '') === name) + const info = JSON.parse(capture('tar', ['-xOf', archive, entry('release.json')])) + validTag(info.version) + if (info.formatVersion !== 1 || !/^[a-f0-9]{40,64}$/.test(info.revision || '') || !/^[a-f0-9]{40,64}$/.test(info.tree || '')) throw new Error('发布包版本信息无效') + if (capture('tar', ['-xOf', archive, entry('dist/version.txt')]) !== info.version) throw new Error('发布包与前端版本标记不一致') + return { archive, info, checksum: sha256(archive) } +} + +export function deploymentTarget(args = {}) { + const host = args.host || '192.168.2.226', user = args.user || 'root', port = String(args.port || 22) + const base = args.directory || '/www/wwwroot/sw2.fuhua91.com' + if (!/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(host) || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(user)) throw new Error('SSH 主机或用户名无效') + if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('SSH 端口无效') + if (!/^\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(base) || base.split('/').some(part => part === '..' || part === '.')) throw new Error('部署目录须为不含空格及相对路径段的绝对路径') + return { host, user, port, base, remote: `${user}@${host}` } +} + +export function fail(error) { + console.error(error.message || String(error)) + process.exitCode = 1 +} + +export function bashExecutable() { + if (process.env.BASH) return process.env.BASH + const gitBash = path.join(process.env.ProgramFiles || 'C:/Program Files', 'Git', 'bin', 'bash.exe') + return os.platform() === 'win32' && fs.existsSync(gitBash) ? gitBash : 'bash' +} diff --git a/web/deploy/release.test.mjs b/web/deploy/release.test.mjs new file mode 100644 index 0000000..4118ba2 --- /dev/null +++ b/web/deploy/release.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { execFileSync } from 'node:child_process' +import { frontendRoot, validTag, shellQuote, deploymentTarget, validateEntries, inspectPackage, bashExecutable } from './release.mjs' +import { installScript } from './deploy-test.mjs' + +test('release and SSH inputs reject paths, options and shell injection', () => { + for (const value of ['', '../release', '-latest', 'a;touch x', 'a\nother']) assert.throws(() => validTag(value)) + assert.equal(validTag('a846bd90-rc.1'), 'a846bd90-rc.1') + for (const args of [{ host: '-oProxyCommand=id' }, { user: 'root;id' }, { directory: '/' }, { directory: '/www/../etc' }, { port: '65536' }]) assert.throws(() => deploymentTarget(args)) + assert.equal(shellQuote("a'b$(id)"), "'a'\\''b$(id)'") + assert.equal(deploymentTarget().base, '/www/wwwroot/sw2.fuhua91.com') +}) + +test('runtime archives exclude backend source and filesystem links', () => { + const required = ['./Dockerfile', './nginx.conf', './deploy.sh', './release.json', './dist/index.html', './dist/version.txt'] + validateEntries(required, required.map(() => '-')) + for (const extra of ['../outside', '/etc/passwd', './dist/../../outside', './backend/app', './internal/data.go', './dist\\..\\outside']) assert.throws(() => validateEntries([...required, extra], required.map(() => '-'))) + for (const type of ['l', 'h', 'p', 'b']) assert.throws(() => validateEntries(required, ['-', type])) +}) + +test('package inspection verifies embedded version and reads the actual archive checksum', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sw-frontend-release-test-')) + try { + const stage = path.join(root, 'stage'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true }) + for (const file of ['Dockerfile', 'nginx.conf', 'deploy.sh', 'dist/index.html']) fs.writeFileSync(path.join(stage, file), 'test\n') + fs.writeFileSync(path.join(stage, 'release.json'), JSON.stringify({ formatVersion: 1, version: 'test-1', revision: 'a'.repeat(40), tree: 'b'.repeat(40) })) + fs.writeFileSync(path.join(stage, 'dist/version.txt'), 'test-1\n') + const archive = path.join(root, 'release.tar.gz') + execFileSync('tar', ['-czf', archive, '-C', stage, '.']) + const result = inspectPackage(archive) + assert.equal(result.info.version, 'test-1') + assert.match(result.checksum, /^[a-f0-9]{64}$/) + fs.writeFileSync(path.join(stage, 'dist/version.txt'), 'wrong-version\n') + execFileSync('tar', ['-czf', archive, '-C', stage, '.']) + assert.throws(() => inspectPackage(archive), /版本标记不一致/) + } finally { + assert.equal(path.dirname(root), path.resolve(os.tmpdir())) + fs.rmSync(root, { recursive: true }) + } +}) + +test('server shell syntax and deployment rollback preserve the backend', () => { + const bash = bashExecutable() + execFileSync(bash, ['-n', 'deploy/deploy.sh'], { cwd: frontendRoot }) + execFileSync(bash, ['-n'], { input: installScript, cwd: frontendRoot }) + const output = execFileSync(bash, ['deploy/deploy.test.sh'], { cwd: frontendRoot, encoding: 'utf8' }) + assert.match(output, /all deployment scenarios passed/) +}) diff --git a/web/deploy/runtime.Dockerfile b/web/deploy/runtime.Dockerfile new file mode 100644 index 0000000..5dd8b11 --- /dev/null +++ b/web/deploy/runtime.Dockerfile @@ -0,0 +1,8 @@ +FROM docker.m.daocloud.io/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236 + +ARG VERSION +LABEL org.opencontainers.image.revision=$VERSION +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY dist/ /usr/share/nginx/html/ +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 diff --git a/web/package.json b/web/package.json index a8e57dd..64bdd3f 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,10 @@ "dev": "vite --host --mode development", "serve": "vite --host --mode development", "build": "vite build --mode production", + "build:docker": "node deploy/build-docker.mjs", + "package:test": "node deploy/build-package.mjs", + "deploy:test": "node deploy/deploy-test.mjs", + "test:release": "node --test deploy/release.test.mjs", "preview": "vite preview --host 0.0.0.0 --mode development", "lint": "eslint .", "lint:fix": "eslint . --fix"