feat: 支持前端独立打包与测试服Docker发布
This commit is contained in:
@@ -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
|
||||
@@ -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` 环境变量指定。
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 '<html>test</html>' > "$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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user