From af2a2a44b33cd04637479f9c9901d8559f3214ed Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 5 Nov 2026 13:08:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E5=89=8D=E7=AB=AFHarb?= =?UTF-8?q?or=E9=95=9C=E5=83=8F=E5=8F=91=E5=B8=83=E4=B8=8E=E6=8B=89?= =?UTF-8?q?=E5=8F=96=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker/README.md | 2 ++ deploy/docker/deploy.sh | 13 +++++++- web/Dockerfile | 5 +-- web/README.md | 4 ++- web/deploy/README.md | 41 +++++++++++++++++------- web/deploy/build-docker.mjs | 16 +++++++--- web/deploy/deploy-test.mjs | 16 ++++++++-- web/deploy/deploy.sh | 63 ++++++++++++++++++++++++++++--------- web/deploy/deploy.test.sh | 53 +++++++++++++++++++++++++------ web/deploy/release.mjs | 2 ++ web/deploy/release.test.mjs | 4 ++- web/docker/pack.ps1 | 42 ++++++++++++++++++------- web/package.json | 1 + 13 files changed, 201 insertions(+), 61 deletions(-) diff --git a/deploy/docker/README.md b/deploy/docker/README.md index fa58983..9710294 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -1,5 +1,7 @@ # sw2 Docker 部署 +前端镜像统一发布至 `harbor.fuhua91.com/game/sw2:<版本>`。发布服务器需先通过 `docker login harbor.fuhua91.com` 配置 push/pull 权限;脚本在替换容器前完成推送、拉取和摘要校验。前端独立发布入口位于前端仓库 `deploy/`,也支持直接指定 Harbor 版本拉取部署。 + 沿用 226 服务器 `/www/wwwroot/live.fuhua91.com` 的 Docker 网络、持久化目录和宝塔反向代理结构。使用独立的 `sw2-backend`、`sw2-frontend`、`sw2-net`,兼容服务器现有 Docker 18.06,不依赖 Compose。 ## 目录与端口 diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 2ee0a67..4531f66 100644 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -8,7 +8,7 @@ MODE="${2:-}" BASE_DIR=/www/wwwroot/sw2.fuhua91.com RELEASE="$BASE_DIR/releases/$TAG" BACKEND_IMAGE="sw2/backend:$TAG" -FRONTEND_IMAGE="sw2/frontend:$TAG" +FRONTEND_IMAGE="harbor.fuhua91.com/game/sw2:$TAG" NETWORK=sw2-net NAMES=(sw2-backend sw2-frontend) @@ -19,6 +19,17 @@ test -f "$RELEASE/frontend/Dockerfile" docker build --build-arg "VERSION=$TAG" -t "$BACKEND_IMAGE" "$RELEASE/backend" docker build --build-arg "VERSION=$TAG" -t "$FRONTEND_IMAGE" "$RELEASE/frontend" +registry_args=() +if [[ -s "$BASE_DIR/harbor-docker/config.json" ]]; then registry_args=(--config "$BASE_DIR/harbor-docker"); fi +docker "${registry_args[@]}" push "$FRONTEND_IMAGE" +docker "${registry_args[@]}" pull "$FRONTEND_IMAGE" +frontend_digests=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$FRONTEND_IMAGE") +frontend_ref='' +while IFS= read -r reference; do + if [[ "$reference" == 'harbor.fuhua91.com/game/sw2@sha256:'* && "${reference##*@}" =~ ^sha256:[a-f0-9]{64}$ ]]; then frontend_ref="$reference"; break; fi +done <<< "$frontend_digests" +[[ -n "$frontend_ref" ]] || { echo 'Harbor frontend digest is missing' >&2; exit 1; } +FRONTEND_IMAGE="$frontend_ref" docker run --rm "$BACKEND_IMAGE" /app/kratos-admin -h >/dev/null docker run --rm --add-host sw2-backend:127.0.0.1 "$FRONTEND_IMAGE" nginx -t if [[ "$MODE" == '--build-only' ]]; then exit 0; fi diff --git a/web/Dockerfile b/web/Dockerfile index 2c0fd60..78ef153 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,6 +1,6 @@ # 与服务器 deploy.sh 对齐: -# docker build --build-arg VERSION=$TAG -t sw2/frontend:$TAG . -# docker run --rm --add-host sw2-backend:127.0.0.1 sw2/frontend:$TAG nginx -t +# docker build --build-arg VERSION=$TAG -t harbor.fuhua91.com/game/sw2:$TAG . +# docker push harbor.fuhua91.com/game/sw2:$TAG # 运行阶段与部署侧 frontend.Dockerfile 一致。本仓库在镜像内编译, # 产物对应部署侧的 COPY dist/。 @@ -19,5 +19,6 @@ ARG VERSION LABEL org.opencontainers.image.revision=$VERSION COPY nginx.sw2.conf /etc/nginx/conf.d/default.conf COPY --from=build /src/dist /usr/share/nginx/html/ +RUN printf '%s\n' "$VERSION" > /usr/share/nginx/html/version.txt 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/README.md b/web/README.md index 9a4af0d..7578306 100644 --- a/web/README.md +++ b/web/README.md @@ -22,7 +22,9 @@ pnpm build ```sh pnpm package:test # 构建并生成带 SHA-256 的前端发布包 pnpm build:docker # 在 Docker 中从前端源码构建镜像 -pnpm deploy:test # 打包并只更新测试服前端 +pnpm push:docker # 构建并推送 harbor.fuhua91.com/game/sw2:<版本> +pnpm deploy:test # 打包、推送 Harbor 并只更新测试服前端 +pnpm deploy:test --image-tag <版本> # 直接拉取已推送的 Harbor 镜像部署 ``` 复用发布包、仅预检、SSH 参数及回退方法见 [前端独立部署说明](deploy/README.md)。 diff --git a/web/deploy/README.md b/web/deploy/README.md index c4b96ae..1085efa 100644 --- a/web/deploy/README.md +++ b/web/deploy/README.md @@ -34,52 +34,70 @@ pnpm package:test --tag web-20261105-01 `--skip-install` 可在已经按锁文件安装依赖后使用;`--output <目录>` 可指定包输出目录。在 `sw-new/web` 中开发时也可运行:检查只覆盖前端,后端本地配置不影响打包。若已在本地生成分仓提交,可传 `--source-ref codex/publish-web`,脚本会验证该提交的根目录与当前前端源码树一致。 -## 3. 从前端源码构建 Docker 镜像 +## 3. 构建并推送 Harbor 镜像 ```sh pnpm build:docker pnpm build:docker --tag web-20261105-01 +# 构建后推送到 Harbor +docker login harbor.fuhua91.com +pnpm push:docker --tag web-20261105-01 ``` -生成 `sw2/frontend:<版本>`。`deploy/Dockerfile` 使用多阶段构建,在 Node 容器安装依赖并构建,再将产物放入 Nginx 镜像。`deploy/runtime.Dockerfile` 用于上面的发布包,服务器无需安装 Node。 +镜像仓库统一为 **`harbor.fuhua91.com/game/sw2:<版本>`**,默认版本仍为源码提交号前 12 位。`build:docker` 只构建,`push:docker` 构建并推送到 Harbor 的 `game/sw2` 仓库。使用已有 Docker 登录凭据或机器人账号,本机推送账号需要 `game/sw2` 的 push 权限,服务器拉取账号需要 pull 权限;密码不写入脚本或发布包。 -仓库根目录原有 `Dockerfile` 仍供 Kubernetes/Jenkins 使用,继续读取 `nginx.k8s.conf`;独立测试服发布使用 `deploy/` 下的 Dockerfile。 +`deploy/Dockerfile` 在 Node 容器内完成源码构建,`deploy/runtime.Dockerfile` 用于发布包的服务器构建。根目录 `Dockerfile` 与 Windows `pnpm pack:docker <版本>` 入口也使用上述 Harbor 仓库;`pack:docker` 构建并推送,传 `-SaveArchive` 可额外导出离线镜像到忽略的 `.cache/docker/`。 + +本机构建检测到 Buildx 时,输出 Linux amd64 的单平台 Docker 镜像并关闭默认证明附件,兼容测试服 Docker 18.06;没有 Buildx 时使用传统构建器。 ## 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 并发布 +# 本机打包,服务器构建并推送 Harbor,再拉取部署 pnpm deploy:test +# 已通过 push:docker 推送:测试服直接从 Harbor 拉取,不再上传包或重新构建 +pnpm deploy:test --image-tag web-20261105-01 + # 复用已经生成的包 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 +# 也可仅预检 Harbor 中的现有镜像 +pnpm deploy:test --image-tag web-20261105-01 --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。 +可用 `--host` 与 `--directory` 覆盖测试服务器和部署根目录。服务器账号需要该目录写权限及 Docker 权限,并已执行 `docker login harbor.fuhua91.com`。发布包模式在服务器执行 push,需 push/pull 权限;`--image-tag` 模式只需 pull 权限。现有的 `sw2-net` 网络、`sw2-backend:8000` 服务与宝塔反向代理应已就绪;服务端脚本兼容 Docker 18.06,不依赖 Docker Compose。 + +若服务器已有其他项目的 Docker 登录,可为本项目前端单独配置。脚本优先读取部署根目录 `harbor-docker/config.json`,不存在时使用当前账号默认 Docker 配置: + +```sh +install -d -m 700 /www/wwwroot/sw2.fuhua91.com/harbor-docker +docker --config /www/wwwroot/sw2.fuhua91.com/harbor-docker login harbor.fuhua91.com +chmod 600 /www/wwwroot/sw2.fuhua91.com/harbor-docker/config.json +``` 发布过程: 1. 验证包结构和版本,上传至 `frontend-releases/`,校验 SHA-256。相同版本只接受同一个发布包。 -2. 构建镜像,运行临时前端容器,检查 `/healthz`、`/version.txt` 和 `/api/health`。 +2. 发布包模式先构建并推送 `harbor.fuhua91.com/game/sw2:<版本>`;两种模式随后都从 Harbor 拉取,取得镜像摘要,按 `harbor.fuhua91.com/game/sw2@sha256:…` 运行临时容器并检查 `/healthz`、`/version.txt` 和 `/api/health`。镜像推送或拉取失败时,当前容器继续运行。 3. 预检通过后,只替换 `sw2-frontend`,仍绑定 `127.0.0.1:9601`。上一版保留为停止状态的 `sw2-frontend-previous`。 -4. 再次检查新容器;失败自动恢复上一版。成功后写入 `current-frontend-release`,后端容器、数据库、后端配置及联合发布的 `current-release` 均不修改。 +4. 正式容器使用同一个已验证的镜像摘要;再次检查失败时自动恢复上一版。成功后写入 `current-frontend-release`,镜像摘要保存在 `frontend-images/<版本>.ref`。后端容器、数据库、后端配置及联合发布的 `current-release` 均不修改。 Nginx 使用 Docker DNS 动态解析后端地址,后端独立重建后无需重建前端镜像。对外仍访问 `http://sw2.fuhua91.com`。 ## 回退与验证 -重新发布保留的旧前端包即可回退,仍只操作前端: +指定 Harbor 中保留的旧版本即可回退,仍只操作前端: ```sh -pnpm deploy:test --archive .cache/deploy/sw2-frontend-<旧版本>.tar.gz +pnpm deploy:test --image-tag <旧版本> ``` 服务端手工执行同样的脚本: @@ -88,9 +106,10 @@ pnpm deploy:test --archive .cache/deploy/sw2-frontend-<旧版本>.tar.gz 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 +docker inspect --format '{{.Config.Image}}' sw2-frontend ``` -发布脚本通过文件锁阻止并发前端发布。永久回归测试覆盖包路径与版本验证、预检失败、发布后失败回退、只构建模式,以及后端容器保持不变: +发布脚本通过文件锁阻止并发前端发布。永久回归测试覆盖包路径与版本验证、Harbor 推送/拉取失败、缺失镜像摘要、直接拉取模式、预检失败及发布后回退,以及后端容器保持不变: ```sh pnpm test:release diff --git a/web/deploy/build-docker.mjs b/web/deploy/build-docker.mjs index f306e2c..d5d2d99 100644 --- a/web/deploy/build-docker.mjs +++ b/web/deploy/build-docker.mjs @@ -1,11 +1,17 @@ -import { options, sourceInfo, run, fail } from './release.mjs' +import { options, sourceInfo, frontendImage, run, fail } from './release.mjs' +import { spawnSync } from 'node:child_process' 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]') + const args = options({ tag: { type: 'string' }, 'source-ref': { type: 'string' }, push: { type: 'boolean' } }) + if (args.help) console.log('pnpm build:docker [--tag VERSION] [--source-ref FRONTEND_COMMIT] [--push]\npnpm push:docker 使用相同参数并将镜像推送到 Harbor。') 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}`) + const image = frontendImage(info.version) + const buildx = spawnSync('docker', ['buildx', 'version'], { stdio: 'ignore' }).status === 0 + // Export a single Docker-format image for the test server's Docker 18.06. + const build = buildx ? ['buildx', 'build', '--platform', 'linux/amd64', '--output', 'type=docker'] : ['build'] + run('docker', [...build, '-f', 'deploy/Dockerfile', '--build-arg', `VERSION=${info.version}`, '--build-arg', `SOURCE_REVISION=${info.revision}`, '--build-arg', `SOURCE_TREE=${info.tree}`, '-t', image, '.'], { env: { ...process.env, BUILDX_NO_DEFAULT_ATTESTATIONS: '1' } }) + if (args.push) run('docker', ['push', image]) + console.log(`${args.push ? '已推送镜像' : '镜像'}:${image}`) } } catch (error) { fail(error) } diff --git a/web/deploy/deploy-test.mjs b/web/deploy/deploy-test.mjs index 47bb29a..3b653ce 100644 --- a/web/deploy/deploy-test.mjs +++ b/web/deploy/deploy-test.mjs @@ -1,7 +1,8 @@ import crypto from 'node:crypto' import path from 'node:path' +import fs from 'node:fs' import { fileURLToPath } from 'node:url' -import { options, packageRelease, inspectPackage, deploymentTarget, shellQuote, run, fail } from './release.mjs' +import { options, packageRelease, inspectPackage, deploymentTarget, shellQuote, frontendRoot, frontendImage, validTag, run, fail } from './release.mjs' export const installScript = `set -euo pipefail BASE_DIR=$(cd -- "$1" && pwd -P) @@ -34,17 +35,26 @@ 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' }, + 'image-tag': { type: 'string' }, 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 仅在服务器构建并预检,不替换当前容器。') + console.log('pnpm deploy:test [--archive FILE.tar.gz | --image-tag VERSION] [--tag VERSION] [--build-only] [--host 192.168.2.226] [--user root] [--port 22] [--identity SSH_KEY] [--directory /www/wwwroot/sw2.fuhua91.com]\n默认打包并在服务器构建、推送 Harbor 后部署;--image-tag 直接拉取已有 Harbor 镜像;--build-only 仅预检,不替换当前容器。') return } if (args.archive && (args.tag || args['source-ref'] || args['skip-install'])) throw new Error('--archive 复用现有包,不能再指定构建参数') + if (args['image-tag'] && (args.archive || args.tag || args['source-ref'] || args['skip-install'])) throw new Error('--image-tag 直接使用 Harbor 镜像,不能同时指定发布包或构建参数') 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] + if (args['image-tag']) { + const tag = validTag(args['image-tag']) + const command = `SW_DEPLOY_BASE=${shellQuote(target.base)} bash -s -- ${shellQuote(tag)} --pull-only${args['build-only'] ? ' --build-only' : ''}` + run('ssh', [...ssh, command], { stdio: ['pipe', 'inherit', 'inherit'], input: fs.readFileSync(path.join(frontendRoot, 'deploy/deploy.sh'), 'utf8').replaceAll('\r\n', '\n') }) + console.log(`${args['build-only'] ? 'Harbor 镜像预检通过' : 'Harbor 镜像部署完成'}:${frontendImage(tag)}`) + return + } + const release = args.archive ? inspectPackage(args.archive) : inspectPackage(packageRelease(args).archive) 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)}`]) diff --git a/web/deploy/deploy.sh b/web/deploy/deploy.sh index a55519b..2d8e631 100644 --- a/web/deploy/deploy.sh +++ b/web/deploy/deploy.sh @@ -2,18 +2,32 @@ set -Eeuo pipefail umask 027 -TAG="${1:?Usage: deploy.sh VERSION [--build-only]}" -MODE="${2:-}" +TAG="${1:?Usage: deploy.sh VERSION [--build-only] [--pull-only]}" +shift +BUILD_ONLY=false +PULL_ONLY=false +for mode in "$@"; do + case "$mode" in + '') ;; + --build-only) BUILD_ONLY=true ;; + --pull-only) PULL_ONLY=true ;; + *) echo 'Invalid deployment mode' >&2; exit 1 ;; + esac +done [[ "$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; } +if [[ -s "$BASE_DIR/harbor-docker/config.json" ]]; then export DOCKER_CONFIG="$BASE_DIR/harbor-docker"; fi +RELEASE='' +if [[ "$PULL_ONLY" == false ]]; then + RELEASE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) + [[ "$RELEASE" == "$BASE_DIR/frontend-releases/$TAG" ]] || { echo 'Release directory does not match version' >&2; exit 1; } +fi NAME=sw2-frontend PREVIOUS=sw2-frontend-previous CANDIDATE="sw2-frontend-candidate-$$" -IMAGE="sw2/frontend:$TAG" +IMAGE_REPOSITORY=harbor.fuhua91.com/game/sw2 +IMAGE="$IMAGE_REPOSITORY:$TAG" NETWORK=sw2-net ATTEMPTS="${SW_HEALTH_ATTEMPTS:-20}" [[ "$ATTEMPTS" =~ ^[1-9][0-9]?$ ]] || { echo 'Invalid health-check attempts' >&2; exit 1; } @@ -34,8 +48,10 @@ if exists "$PREVIOUS"; then 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; } +if [[ "$PULL_ONLY" == false ]]; then + test -s "$RELEASE/dist/index.html" + [[ "$(cat "$RELEASE/dist/version.txt")" == "$TAG" ]] || { echo 'Frontend version marker mismatch' >&2; exit 1; } +fi old_present=false old_stopped=false @@ -81,15 +97,32 @@ healthy() { return 1 } -docker build --build-arg "VERSION=$TAG" -t "$IMAGE" "$RELEASE" -docker run --rm "$IMAGE" nginx -t +if [[ "$PULL_ONLY" == false ]]; then + docker build --build-arg "VERSION=$TAG" -t "$IMAGE" "$RELEASE" + docker push "$IMAGE" +fi +# Resolve the registry result and pin every run to its digest. Authentication +# uses the server's docker login; credentials never enter the release package. +docker pull "$IMAGE" +digests=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE") +IMAGE_REF='' +while IFS= read -r reference; do + if [[ "$reference" == "$IMAGE_REPOSITORY@sha256:"* && "${reference##*@}" =~ ^sha256:[a-f0-9]{64}$ ]]; then + IMAGE_REF="$reference" + break + fi +done <<< "$digests" +[[ -n "$IMAGE_REF" ]] || { echo 'Harbor image digest is missing' >&2; exit 1; } +mkdir -p "$BASE_DIR/frontend-images" +printf '%s\n' "$IMAGE_REF" > "$BASE_DIR/frontend-images/$TAG.ref" +docker run --rm "$IMAGE_REF" 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 + --network "$NETWORK" -p 127.0.0.1::80 "$IMAGE_REF" >/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" +if [[ "$BUILD_ONLY" == true ]]; then + echo "Harbor frontend image and API proxy preflight passed: $IMAGE_REF" exit 0 fi @@ -104,7 +137,7 @@ 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 + -p 127.0.0.1:9601:80 "$IMAGE_REF" >/dev/null healthy http://127.0.0.1:9601 || { echo 'Published frontend failed health checks' >&2; exit 1; } if [[ -n "$old_version" ]]; then @@ -114,4 +147,4 @@ 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" +printf 'Frontend deployed: %s\nImage: %s\nBackend container was not modified.\n' "$TAG" "$IMAGE_REF" diff --git a/web/deploy/deploy.test.sh b/web/deploy/deploy.test.sh index 7e36e72..85e11f1 100644 --- a/web/deploy/deploy.test.sh +++ b/web/deploy/deploy.test.sh @@ -17,6 +17,11 @@ case "$command" in inspect) format='' if [[ "$1" == --format ]]; then format=$2; shift 2; fi + if [[ "$format" == *RepoDigests* ]]; then + [[ "$1" == harbor.fuhua91.com/game/sw2:new ]] || exit 1 + if [[ "$TEST_MODE" != digest-missing ]]; then printf 'harbor.fuhua91.com/game/sw2@sha256:%064d\n' 1; fi + exit 0 + fi [[ -d "$TEST_STATE/$1" ]] || exit 1 case "$format" in *com.sw2.application*) cat "$TEST_STATE/$1/app" ;; @@ -24,6 +29,8 @@ case "$command" in *State.Running*) cat "$TEST_STATE/$1/running" ;; esac ;; network|build) exit 0 ;; + push) [[ "$TEST_MODE" != push-fail ]] ;; + pull) [[ "$TEST_MODE" != pull-fail ]] ;; run) name=''; version='new' while (($#)); do @@ -60,14 +67,17 @@ 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 +for scenario in success preflight-fail final-fail build-only pull-only pull-preflight push-fail pull-fail digest-missing 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" + mkdir -p "$SW_DEPLOY_BASE" "$TEST_STATE/sw2-frontend" "$TEST_STATE/sw2-backend" + if [[ "$scenario" != pull-only && "$scenario" != pull-preflight ]]; then + mkdir -p "$SW_DEPLOY_BASE/frontend-releases/new/dist" + 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" + fi printf 'old\n' > "$SW_DEPLOY_BASE/current-frontend-release" printf 'sw2' > "$TEST_STATE/sw2-frontend/app" printf 'old' > "$TEST_STATE/sw2-frontend/version" @@ -75,14 +85,21 @@ for scenario in success preflight-fail final-fail build-only unmanaged; do 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=$? + modes=() case "$scenario" in - success) + build-only) modes=(--build-only) ;; + pull-only) modes=(--pull-only) ;; + pull-preflight) modes=(--pull-only --build-only) ;; + esac + result=0 + entry="$SW_DEPLOY_BASE/frontend-releases/new/deploy.sh" + if [[ "$scenario" == pull-only || "$scenario" == pull-preflight ]]; then entry="$SCRIPT"; fi + bash "$entry" new "${modes[@]}" > "$TEST_STATE/output" 2>&1 || result=$? + case "$scenario" in + success|pull-only) [[ "$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) + build-only|pull-preflight) [[ "$result" == 0 && "$(cat "$TEST_STATE/sw2-frontend/id")" == old-container ]] [[ "$(cat "$SW_DEPLOY_BASE/current-frontend-release")" == old ]] ;; *) @@ -91,6 +108,22 @@ for scenario in success preflight-fail final-fail build-only unmanaged; do esac [[ "$(cat "$TEST_STATE/sw2-backend/id")" == backend-unchanged ]] ! grep -E '(stop|start|rename|rm|run).*sw2-backend' "$TEST_STATE/calls" + ! grep -F 'sw2/frontend:' "$TEST_STATE/calls" + if [[ "$scenario" == success || "$scenario" == build-only ]]; then + grep -q '^push harbor.fuhua91.com/game/sw2:new$' "$TEST_STATE/calls" + grep -q '^pull harbor.fuhua91.com/game/sw2:new$' "$TEST_STATE/calls" + fi + if [[ "$scenario" == pull-only || "$scenario" == pull-preflight ]]; then + ! grep -E '^(build|push) ' "$TEST_STATE/calls" + grep -q '^pull harbor.fuhua91.com/game/sw2:new$' "$TEST_STATE/calls" + fi + if [[ "$scenario" == success || "$scenario" == pull-only ]]; then + grep -q 'harbor.fuhua91.com/game/sw2@sha256:' "$TEST_STATE/calls" + grep -q '^harbor.fuhua91.com/game/sw2@sha256:' "$SW_DEPLOY_BASE/frontend-images/new.ref" + fi + if [[ "$scenario" == push-fail || "$scenario" == pull-fail || "$scenario" == digest-missing ]]; then + ! grep -E '^(stop|rename) ' "$TEST_STATE/calls" + fi [[ "$(find "$TEST_STATE" -maxdepth 1 -name 'sw2-frontend-candidate-*' | wc -l)" == 0 ]] done echo 'all deployment scenarios passed' diff --git a/web/deploy/release.mjs b/web/deploy/release.mjs index 3ce6543..755b7b9 100644 --- a/web/deploy/release.mjs +++ b/web/deploy/release.mjs @@ -7,6 +7,8 @@ import { execFileSync, spawnSync } from 'node:child_process' import { parseArgs } from 'node:util' export const frontendRoot = fileURLToPath(new URL('../', import.meta.url)) +export const imageRepository = 'harbor.fuhua91.com/game/sw2' +export const frontendImage = tag => `${imageRepository}:${validTag(tag)}` export const options = (definitions) => parseArgs({ options: { help: { type: 'boolean' }, ...definitions }, args: process.argv.slice(2).filter((arg, i) => !(i === 0 && arg === '--')) diff --git a/web/deploy/release.test.mjs b/web/deploy/release.test.mjs index 4118ba2..4fde161 100644 --- a/web/deploy/release.test.mjs +++ b/web/deploy/release.test.mjs @@ -4,12 +4,14 @@ 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 { frontendRoot, frontendImage, 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') + assert.equal(frontendImage('a846bd90-rc.1'), 'harbor.fuhua91.com/game/sw2:a846bd90-rc.1') + assert.throws(() => frontendImage('../other')) 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') diff --git a/web/docker/pack.ps1 b/web/docker/pack.ps1 index 028e2ba..51c28c4 100644 --- a/web/docker/pack.ps1 +++ b/web/docker/pack.ps1 @@ -1,27 +1,45 @@ param( [Parameter(Position = 0)] - [string]$Tag = (Get-Date -Format 'yyyyMMdd') + [string]$Tag = (Get-Date -Format 'yyyyMMdd'), + [switch]$SaveArchive ) $ErrorActionPreference = 'Stop' -if ($Tag -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$') { +if ($Tag -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$') { Write-Error "无效版本号: $Tag" } $Root = Split-Path -Parent $PSScriptRoot -$OutDir = $PSScriptRoot -$Image = "sw2/frontend:$Tag" +$OutDir = Join-Path $Root '.cache\docker' +$Image = "harbor.fuhua91.com/game/sw2:$Tag" $Tar = Join-Path $OutDir "sw2-frontend-$Tag.tar" Write-Host "构建 $Image" -docker build --build-arg "VERSION=$Tag" -t $Image $Root -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +$previousAttestations = [Environment]::GetEnvironmentVariable('BUILDX_NO_DEFAULT_ATTESTATIONS', 'Process') +try { + $env:BUILDX_NO_DEFAULT_ATTESTATIONS = '1' + docker buildx version *> $null + if ($LASTEXITCODE -eq 0) { + docker buildx build --platform linux/amd64 --output type=docker --build-arg "VERSION=$Tag" -t $Image $Root + } else { + docker build --build-arg "VERSION=$Tag" -t $Image $Root + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + [Environment]::SetEnvironmentVariable('BUILDX_NO_DEFAULT_ATTESTATIONS', $previousAttestations, 'Process') +} -if (Test-Path $Tar) { Remove-Item $Tar -Force } -Write-Host "导出 $Tar" -docker save -o $Tar $Image +Write-Host "推送 $Image" +docker push $Image if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - -$Item = Get-Item $Tar -Write-Host ("完成 {0} {1:N1} MB" -f $Item.FullName, ($Item.Length / 1MB)) +if ($SaveArchive) { + New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + if (Test-Path -LiteralPath $Tar) { throw "镜像归档已存在:$Tar" } + Write-Host "导出 $Tar" + docker save -o $Tar $Image + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $Item = Get-Item -LiteralPath $Tar + Write-Host ("完成 {0} {1:N1} MB" -f $Item.FullName, ($Item.Length / 1MB)) +} +Write-Host "Harbor 镜像:$Image" diff --git a/web/package.json b/web/package.json index 83cccfe..a1c9183 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "serve": "vite --host --mode development", "build": "vite build --mode production", "build:docker": "node deploy/build-docker.mjs", + "push:docker": "node deploy/build-docker.mjs --push", "package:test": "node deploy/build-package.mjs", "deploy:test": "node deploy/deploy-test.mjs", "test:release": "node --test deploy/release.test.mjs",