From 879a53bf8dc365f64a30d61a2544a0684c6d5d15 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 2 May 2026 02:58:14 +0800 Subject: [PATCH 01/25] docs: add production deployment plan --- 生产部署计划.md | 316 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 生产部署计划.md diff --git a/生产部署计划.md b/生产部署计划.md new file mode 100644 index 00000000..c5f72169 --- /dev/null +++ b/生产部署计划.md @@ -0,0 +1,316 @@ +# 生产部署计划 + +更新时间:2026-05-02 + +## 目标 + +将当前部署方式调整为单机生产推荐方案:生产运行路径不使用 Docker,不再使用旧的一体化启动脚本,由 systemd 托管 SpacetimeDB 与 Rust `api-server`,由 Nginx 托管主站、后台前端与必要反向代理。 + +本计划用于重新创建 Jenkins 流水线、服务器环境配置、网站发布、`api-server` 发布、SpacetimeDB 模块发布,以及数据库人工导入导出流程。 + +## 生产架构 + +- Nginx 作为唯一公网入口,负责 HTTPS、静态站点、后台静态页面、维护页与 `/admin/api/` 反向代理。 +- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3000`,数据根目录为 `/stdb`。 +- Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。 +- 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。 +- 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。 + +## 服务器目录 + +- `/opt/genarrative/releases//`:每次发布的完整版本目录。 +- `/opt/genarrative/current`:指向当前生效版本的软链接。 +- `/srv/genarrative/web`:指向 `/opt/genarrative/current/web`,供 Nginx 托管静态站点。 +- `/etc/genarrative/api-server.env`:`api-server` 生产环境变量文件。 +- `/var/lib/genarrative/maintenance/enabled`:维护模式开关文件。 +- `/stdb`:SpacetimeDB 程序、配置与数据根目录。 + +## 生产密钥 + +`/etc/genarrative/api-server.env` 中的生产密钥指所有只能存在于生产服务器、不能进入 Git、不能进入构建产物的敏感配置。典型内容包括: + +- LLM 或第三方服务 API Key。 +- 短信服务 Access Key 与 Secret。 +- 后台登录、会话、签名、加密相关密钥。 +- 生产 SpacetimeDB 地址与数据库名。 +- 只允许生产使用的回调地址、白名单或内部令牌。 + +该文件由服务器配置流水线或人工初始化创建,权限建议为 `root:genarrative`、`0640`。Jenkins 构建任务不能读取该文件;只有生产发布或服务启动需要读取。 + +## systemd 服务 + +### SpacetimeDB + +- 服务名:`spacetimedb.service` +- 运行用户:`spacetimedb` +- 工作目录:`/stdb` +- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000` +- 对外暴露:默认不直接暴露公网端口。 + +该方案与 SpacetimeDB 官方自托管文档一致:使用 Ubuntu、专用用户、`/stdb` 根目录、systemd 服务和 Nginx。 + +### api-server + +- 服务名:`genarrative-api.service` +- 运行用户:`genarrative` +- 工作目录:`/opt/genarrative/current` +- 可执行文件:`/opt/genarrative/current/api-server` +- 环境文件:`/etc/genarrative/api-server.env` +- 监听地址:`127.0.0.1:8082` + +`api-server` 不放入 Docker,也不直接暴露公网端口。发布时替换版本目录并重启 `genarrative-api.service`。 + +## Nginx 规则 + +只保留必要入口: + +- `/`:主站静态页面。 +- `/admin/`:后台前端静态页面,后台构建时使用 `/admin/` 作为 base path。 +- `/admin/api/`:反向代理到 `http://127.0.0.1:8082/admin/api/`。 +- HTTP 到 HTTPS:只保留 301 重定向。 +- `/maintenance.html`:维护中页面。 + +移除这些公网反向代理: + +- `/api/*` +- `/generated-*` +- 公网 `/healthz` +- 其他旧的一体化 web server 代理入口。 + +SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。 + +## 维护模式 + +维护模式由 `/var/lib/genarrative/maintenance/enabled` 控制: + +- 文件存在:进入维护模式。 +- 文件不存在:退出维护模式。 + +行为: + +- 网站静态资源发布不进入维护模式。 +- `api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。 +- 普通页面在维护模式下展示 `/maintenance.html`。 +- `/admin/api/*` 在维护模式下返回 503。 +- 静态资源仍允许访问,避免维护页样式和资源加载失败。 +- 发布成功后自动解除维护模式。 +- 发布失败时保持维护模式,并通过邮件通知人工处理。 + +## 构建产物 + +每次构建产物按版本号归档: + +```text +build// +├─ web/ +│ ├─ index.html +│ ├─ assets/ +│ ├─ maintenance.html +│ └─ admin/ +├─ api-server +├─ spacetime_module.wasm +├─ scripts/ +│ ├─ spacetime-publish-prod.sh +│ ├─ database-export.sh +│ ├─ database-import.sh +│ ├─ maintenance-on.sh +│ ├─ maintenance-off.sh +│ └─ maintenance-status.sh +├─ deploy/ +│ ├─ systemd/ +│ │ ├─ spacetimedb.service +│ │ └─ genarrative-api.service +│ ├─ nginx/ +│ │ ├─ genarrative.conf +│ │ └─ snippets/maintenance.conf +│ └─ env/api-server.env.example +└─ README.md +``` + +不再生成旧产物: + +- `web-server.mjs` +- 旧的一体化 `start.sh` +- 旧的一体化 `stop.sh` + +## Jenkins 节点 + +Jenkins 可运行在 Windows 或其他机器上,但构建与发布动作使用 Linux agent。 + +### 开发/构建实例 + +- 节点名:`genarrative-dev` +- 标签:`genarrative-linux dev build` +- 用途:拉代码、安装依赖、构建主站、构建后台、构建 `api-server`、构建 SpacetimeDB wasm、归档产物。 + +### 生产/发布实例 + +- 节点名:`genarrative-prod` +- 标签:`genarrative-linux prod deploy` +- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 + +### SSH PEM 凭证 + +在 Jenkins 中使用 `SSH Username with private key` 类型添加 PEM 私钥: + +- `genarrative-dev-ssh-key`:开发/构建实例 SSH 凭证。 +- `genarrative-prod-ssh-key`:生产/发布实例 SSH 凭证。 + +推荐使用非 root 用户,例如 `jenkins`。该用户只通过 sudoers 获得必要命令权限,例如 `systemctl restart genarrative-api`、`nginx -t`、维护脚本、发布目录切换等。 + +## Jenkins 流水线 + +计划重新创建以下流水线: + +1. `Genarrative-Server-Provision` +2. `Genarrative-Web-Build` +3. `Genarrative-Web-Deploy` +4. `Genarrative-Api-Build` +5. `Genarrative-Api-Deploy` +6. `Genarrative-Stdb-Module-Build` +7. `Genarrative-Stdb-Module-Publish` +8. `Genarrative-Database-Export` +9. `Genarrative-Database-Import` +10. `Genarrative-Full-Build-And-Deploy` + +构建流水线运行在 `build && dev`,发布、导入导出和服务器配置流水线运行在 `deploy && prod`。 + +构建流水线支持参数 `PUBLISH_AFTER_BUILD`: + +- `false`:只构建并归档产物。 +- `true`:构建成功后触发对应发布流水线。 + +发布流水线必须从归档产物获取文件,不依赖构建 workspace 的本地状态。 + +## 流水线职责 + +### Genarrative-Server-Provision + +用于生产服务器一次性或低频配置: + +- 创建 `spacetimedb`、`genarrative` 等系统用户。 +- 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 +- 安装或更新 SpacetimeDB。 +- 安装 systemd unit。 +- 安装 Nginx 配置和维护模式 snippet。 +- 执行 `nginx -t`。 +- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 + +该流水线属于高风险操作,默认要求人工确认后执行。 + +### Web Build / Deploy + +构建: + +- 构建主站静态文件。 +- 构建后台前端,base path 为 `/admin/`。 +- 生成或复制 `maintenance.html`。 +- 归档 `web/` 产物。 + +发布: + +- 解包到 `/opt/genarrative/releases//web`。 +- 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 +- 执行 Nginx 配置测试和静态页面 smoke test。 +- 不进入维护模式。 + +### Api Build / Deploy + +构建: + +- 编译 Rust `api-server`。 +- 归档单一可执行文件和必要运行说明。 + +发布: + +- 进入维护模式。 +- 解包到 `/opt/genarrative/releases//api-server`。 +- 更新 `/opt/genarrative/current`。 +- 重启 `genarrative-api.service`。 +- 检查本机 `/healthz`。 +- 成功后解除维护模式。 +- 失败时保留维护模式并发邮件。 + +### Stdb Module Build / Publish + +构建: + +- 使用 `spacetime build` 构建 `spacetime_module.wasm`。 +- 归档 wasm 与发布脚本。 + +发布: + +- 进入维护模式。 +- 将 wasm 上传到生产实例。 +- 在生产实例本机执行 `spacetime publish -s local --bin-path spacetime_module.wasm `。 +- 成功后执行必要 smoke test。 +- 成功后解除维护模式。 +- 失败时保留维护模式并发邮件。 + +## 数据库导出与导入 + +### 导出 + +`Genarrative-Database-Export` 用于人工导出生产数据: + +- 运行在 `deploy && prod`。 +- 进入维护模式,避免导出期间继续写入。 +- 从本机 SpacetimeDB 导出指定数据库数据。 +- 产物归档到 Jenkins,并可额外保存到服务器备份目录。 +- 成功后解除维护模式。 +- 失败时保留维护模式并邮件通知。 + +### 导入 + +`Genarrative-Database-Import` 用于人工导入或恢复数据: + +- 运行在 `deploy && prod`。 +- 必须要求人工确认目标数据库、导入文件和是否覆盖。 +- 进入维护模式。 +- 导入前先生成一次安全备份。 +- 执行导入。 +- 执行数据校验和服务 smoke test。 +- 成功后解除维护模式。 +- 失败时保留维护模式并邮件通知。 + +数据库表结构变更必须同步检查并更新 `migration.rs`,不能只发布 wasm。 + +## 全量构建并发布 + +`Genarrative-Full-Build-And-Deploy` 顺序: + +1. 触发 `Genarrative-Web-Build`。 +2. 触发 `Genarrative-Api-Build`。 +3. 触发 `Genarrative-Stdb-Module-Build`。 +4. 触发 `Genarrative-Stdb-Module-Publish`。 +5. 触发 `Genarrative-Api-Deploy`。 +6. 触发 `Genarrative-Web-Deploy`。 +7. 执行生产 smoke test。 + +网站最后发布,避免后台或主站提前指向尚未完成发布的后端能力。 + +## 回滚 + +- 网站回滚:将 `/srv/genarrative/web` 或 `/opt/genarrative/current` 切回上一版本并 reload Nginx。 +- `api-server` 回滚:将 `/opt/genarrative/current` 切回上一版本并重启 `genarrative-api.service`。 +- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。 +- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。 + +## 待落地文件 + +后续工程落地时需要新增或改造: + +- `deploy/systemd/spacetimedb.service` +- `deploy/systemd/genarrative-api.service` +- `deploy/nginx/genarrative.conf` +- `deploy/nginx/snippets/maintenance.conf` +- `deploy/env/api-server.env.example` +- `scripts/deploy/maintenance-on.sh` +- `scripts/deploy/maintenance-off.sh` +- `scripts/deploy/maintenance-status.sh` +- 新 Jenkinsfile 或 Job DSL,用于上述 10 条流水线。 +- 更新旧部署文档,标记旧一体化脚本为废弃或迁移对象。 + +## 参考 + +- SpacetimeDB 官方自托管文档:https://spacetimedb.com/docs/how-to/deploy/self-hosting/ From bdc32570038c2081cab47425e807ccd61c62e342 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:14:13 +0800 Subject: [PATCH 02/25] Add production Jenkins release pipelines --- deploy/env/api-server.env.example | 76 +++ deploy/nginx/genarrative.conf | 101 ++++ .../snippets/genarrative-maintenance.conf | 12 + deploy/systemd/genarrative-api.service | 26 + deploy/systemd/spacetimedb.service | 23 + docs/README.md | 4 +- ..._RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md | 37 +- ...DATABASE_MIGRATION_PIPELINES_2026-04-29.md | 74 +-- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 518 ++++++++++++++++++ docs/technical/README.md | 11 +- ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 2 + ..._AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md | 2 + jenkins/Jenkinsfile.build-and-deploy | 202 ------- jenkins/Jenkinsfile.database-export | 103 ---- jenkins/Jenkinsfile.database-import | 126 ----- jenkins/Jenkinsfile.deploy | 162 ------ jenkins/Jenkinsfile.production-api-build | 105 ++++ jenkins/Jenkinsfile.production-api-deploy | 119 ++++ .../Jenkinsfile.production-database-export | 198 +++++++ .../Jenkinsfile.production-database-import | 315 +++++++++++ ...nkinsfile.production-full-build-and-deploy | 176 ++++++ .../Jenkinsfile.production-server-provision | 241 ++++++++ .../Jenkinsfile.production-stdb-module-build | 107 ++++ ...Jenkinsfile.production-stdb-module-publish | 122 +++++ jenkins/Jenkinsfile.production-web-build | 104 ++++ jenkins/Jenkinsfile.production-web-deploy | 117 ++++ package.json | 1 + scripts/build-production-release.sh | 413 ++++++++++++++ scripts/deploy/maintenance-off.sh | 12 + scripts/deploy/maintenance-on.sh | 15 + scripts/deploy/maintenance-status.sh | 12 + scripts/deploy/production-api-deploy.sh | 138 +++++ scripts/deploy/production-stdb-publish.sh | 123 +++++ scripts/deploy/production-web-deploy.sh | 123 +++++ scripts/jenkins-checkout-source.sh | 54 ++ server-rs/.cargo/config.toml | 3 + server-rs/crates/api-server/Cargo.toml | 4 - 生产部署计划.md | 316 ----------- 38 files changed, 3315 insertions(+), 982 deletions(-) create mode 100644 deploy/env/api-server.env.example create mode 100644 deploy/nginx/genarrative.conf create mode 100644 deploy/nginx/snippets/genarrative-maintenance.conf create mode 100644 deploy/systemd/genarrative-api.service create mode 100644 deploy/systemd/spacetimedb.service create mode 100644 docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md delete mode 100644 jenkins/Jenkinsfile.build-and-deploy delete mode 100644 jenkins/Jenkinsfile.database-export delete mode 100644 jenkins/Jenkinsfile.database-import delete mode 100644 jenkins/Jenkinsfile.deploy create mode 100644 jenkins/Jenkinsfile.production-api-build create mode 100644 jenkins/Jenkinsfile.production-api-deploy create mode 100644 jenkins/Jenkinsfile.production-database-export create mode 100644 jenkins/Jenkinsfile.production-database-import create mode 100644 jenkins/Jenkinsfile.production-full-build-and-deploy create mode 100644 jenkins/Jenkinsfile.production-server-provision create mode 100644 jenkins/Jenkinsfile.production-stdb-module-build create mode 100644 jenkins/Jenkinsfile.production-stdb-module-publish create mode 100644 jenkins/Jenkinsfile.production-web-build create mode 100644 jenkins/Jenkinsfile.production-web-deploy create mode 100644 scripts/build-production-release.sh create mode 100644 scripts/deploy/maintenance-off.sh create mode 100644 scripts/deploy/maintenance-on.sh create mode 100644 scripts/deploy/maintenance-status.sh create mode 100644 scripts/deploy/production-api-deploy.sh create mode 100644 scripts/deploy/production-stdb-publish.sh create mode 100644 scripts/deploy/production-web-deploy.sh create mode 100644 scripts/jenkins-checkout-source.sh create mode 100644 server-rs/.cargo/config.toml delete mode 100644 生产部署计划.md diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example new file mode 100644 index 00000000..25016c80 --- /dev/null +++ b/deploy/env/api-server.env.example @@ -0,0 +1,76 @@ +# 复制到 /etc/genarrative/api-server.env 后再填入真实生产值。 +# 该文件只能保存在生产服务器,不进入构建产物,不提交真实密钥。 + +GENARRATIVE_ENV=production +GENARRATIVE_API_HOST=127.0.0.1 +GENARRATIVE_API_PORT=8082 +GENARRATIVE_API_LOG=info,tower_http=info + +GENARRATIVE_ADMIN_USERNAME= +GENARRATIVE_ADMIN_PASSWORD= +GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS=14400 +GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_PRODUCTION + +GENARRATIVE_JWT_ISSUER=genarrative-production +GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_PRODUCTION +GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS=7200 +AUTH_REFRESH_COOKIE_NAME=genarrative_refresh_session +AUTH_REFRESH_SESSION_TTL_DAYS=30 +AUTH_REFRESH_COOKIE_PATH=/api/auth +AUTH_REFRESH_COOKIE_SAME_SITE=Lax +AUTH_REFRESH_COOKIE_SECURE=true +GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json +GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false + +GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3000 +GENARRATIVE_SPACETIME_DATABASE=genarrative-prod +GENARRATIVE_SPACETIME_TOKEN= +GENARRATIVE_SPACETIME_POOL_SIZE=8 +GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 + +GENARRATIVE_LLM_PROVIDER=openai-compatible +GENARRATIVE_LLM_BASE_URL= +GENARRATIVE_LLM_API_KEY= +GENARRATIVE_LLM_MODEL= +GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=false +GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false + +DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 +DASHSCOPE_API_KEY= +DASHSCOPE_IMAGE_MODEL=wan2.7-image +DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS=150000 +DASHSCOPE_CHARACTER_VISUAL_MODEL=wan2.7-image-pro +DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL=wan2.7-image-pro +DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL=wan2.7-r2v +DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL=wan2.2-animate-move +DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000 + +SMS_AUTH_ENABLED=false +SMS_AUTH_PROVIDER=aliyun +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= +ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_TEMPLATE_PARAM_KEY=code +ALIYUN_SMS_COUNTRY_CODE=86 + +WECHAT_AUTH_ENABLED=false +WECHAT_AUTH_PROVIDER=real +WECHAT_APP_ID= +WECHAT_APP_SECRET= +WECHAT_CALLBACK_PATH=/api/auth/wechat/callback +WECHAT_REDIRECT_PATH=/ +WECHAT_AUTHORIZE_ENDPOINT=https://open.weixin.qq.com/connect/qrconnect +WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token +WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo +WECHAT_STATE_TTL_MINUTES=15 + +ALIYUN_OSS_BUCKET= +ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID= +ALIYUN_OSS_ACCESS_KEY_SECRET= +ALIYUN_OSS_READ_EXPIRE_SECONDS=600 +ALIYUN_OSS_POST_EXPIRE_SECONDS=600 +ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520 +ALIYUN_OSS_SUCCESS_ACTION_STATUS=200 diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf new file mode 100644 index 00000000..ca2b388e --- /dev/null +++ b/deploy/nginx/genarrative.conf @@ -0,0 +1,101 @@ +# 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。 +server { + listen 80; + server_name genarrative.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name genarrative.example.com; + + ssl_certificate /etc/letsencrypt/live/genarrative.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/genarrative.example.com/privkey.pem; + + root /srv/genarrative/web; + index index.html; + + include /etc/nginx/snippets/genarrative-maintenance.conf; + + location ^~ /admin/api/ { + default_type application/json; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://127.0.0.1:8082/admin/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + } + + location = /admin { + return 301 /admin/; + } + + location ^~ /admin/assets/ { + try_files $uri =404; + } + + location ^~ /admin/ { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /admin/index.html; + } + + location ^~ /assets/ { + try_files $uri =404; + } + + # 生产公网不再暴露旧一体化 API、生成资源代理和健康检查入口。 + location ~ ^/(api|generated-|healthz) { + return 404; + } + + # SpacetimeDB 只开放 TypeScript SDK 运行所需的最小公网路由。 + location ~ ^/v1/database/[^/]+/subscribe$ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + location ^~ /v1/identity { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + location ^~ /v1/ { + return 404; + } + + location / { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/nginx/snippets/genarrative-maintenance.conf b/deploy/nginx/snippets/genarrative-maintenance.conf new file mode 100644 index 00000000..e6c844fd --- /dev/null +++ b/deploy/nginx/snippets/genarrative-maintenance.conf @@ -0,0 +1,12 @@ +# 维护模式由发布脚本或人工运维通过固定文件控制。 +# 文件存在时,普通页面展示维护页,管理 API 返回 503。 +set $genarrative_maintenance 0; +if (-f /var/lib/genarrative/maintenance/enabled) { + set $genarrative_maintenance 1; +} + +location = /maintenance.html { + root /srv/genarrative/web; + add_header Cache-Control "no-store"; + internal; +} diff --git a/deploy/systemd/genarrative-api.service b/deploy/systemd/genarrative-api.service new file mode 100644 index 00000000..1a22b75d --- /dev/null +++ b/deploy/systemd/genarrative-api.service @@ -0,0 +1,26 @@ +[Unit] +Description=Genarrative Rust API Server +After=network-online.target spacetimedb.service +Wants=network-online.target +Requires=spacetimedb.service + +[Service] +Type=simple +User=genarrative +Group=genarrative +WorkingDirectory=/opt/genarrative/current +EnvironmentFile=/etc/genarrative/api-server.env +ExecStart=/opt/genarrative/current/api-server +Restart=always +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=30 + +# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/opt/genarrative /var/lib/genarrative + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/spacetimedb.service b/deploy/systemd/spacetimedb.service new file mode 100644 index 00000000..16b236f9 --- /dev/null +++ b/deploy/systemd/spacetimedb.service @@ -0,0 +1,23 @@ +[Unit] +Description=SpacetimeDB Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=spacetimedb +Group=spacetimedb +WorkingDirectory=/stdb +ExecStart=/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000 +Restart=always +RestartSec=5 +LimitNOFILE=1048576 + +# 生产库只监听本机端口,由 Nginx 暴露最小客户端路由。 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/stdb + +[Install] +WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index 7861eaf3..bef8aaaf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,14 +13,14 @@ 重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 - [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。 -SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 +生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。 3. 需要排期时看 [规划与优先级](./planning/README.md)。 -4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 +4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及生产发布链路先看 [生产部署计划](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 5. 需要对齐目标边界时再进入 [PRD](./prd)。 ## 分类规则 diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index d42303f6..013534a8 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -2,6 +2,8 @@ 日期:`2026-04-23` +状态:历史方案,已被 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 中的 `jenkins/Jenkinsfile.production-*` 生产流水线替代。旧 Jenkinsfile 已从仓库删除,本文只保留旧本地目录部署链的设计背景和迁移参考,不能再作为新 Jenkins Job 的脚本路径来源。 + ## 1. 目标 本方案为当前仓库补齐 3 条 Jenkins 流水线: @@ -47,11 +49,7 @@ ### 4.1 构建 -脚本路径: - -```text -jenkins/Jenkinsfile.build -``` +旧脚本路径:`jenkins/Jenkinsfile.build`。该文件当前仓库已不存在;生产构建改用 `jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-stdb-module-build`。 核心流程: @@ -74,11 +72,7 @@ BUILD_VERSION = Jenkins BUILD_NUMBER ### 4.2 部署 -脚本路径: - -```text -jenkins/Jenkinsfile.deploy -``` +旧脚本路径:`jenkins/Jenkinsfile.deploy`。该文件当前仓库已删除;生产发布改用 `jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`。 核心流程: @@ -114,11 +108,7 @@ scripts/jenkins-deploy-release.sh \ ### 4.3 构建并部署 -脚本路径: - -```text -jenkins/Jenkinsfile.build-and-deploy -``` +旧脚本路径:`jenkins/Jenkinsfile.build-and-deploy`。该文件当前仓库已删除;全量编排改用 `jenkins/Jenkinsfile.production-full-build-and-deploy`,并按 Web / API / Stdb 并行构建、Stdb / API / Web 顺序发布执行。 核心流程: @@ -198,11 +188,11 @@ jenkins ALL=(root) NOPASSWD: /var/lib/jenkins/deploy/Genarrative/stop.sh ## 6. 推荐 Job 命名 -建议在 Jenkins 中创建以下 3 个 Pipeline Job,并分别指向仓库中的脚本路径: +以下旧 Job 命名只保留为历史记录,不再创建或关联到仓库脚本: -1. `Genarrative-Build` -> `jenkins/Jenkinsfile.build` -2. `Genarrative-Deploy` -> `jenkins/Jenkinsfile.deploy` -3. `Genarrative-Build-And-Deploy` -> `jenkins/Jenkinsfile.build-and-deploy` +1. `Genarrative-Build` +2. `Genarrative-Deploy` +3. `Genarrative-Build-And-Deploy` 同时给 `Genarrative-Deploy` 配置环境变量: @@ -218,14 +208,7 @@ game/Genarrative-Build-And-Deploy ## 7. 文件清单 -本方案对应的仓库文件: - -```text -jenkins/Jenkinsfile.build -jenkins/Jenkinsfile.deploy -jenkins/Jenkinsfile.build-and-deploy -scripts/jenkins-deploy-release.sh -``` +本方案原对应 Jenkinsfile 已删除。生产发布链的当前文件清单见 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`;旧部署脚本如 `scripts/jenkins-deploy-release.sh` 只作为旧发布包链路参考,不再作为生产 Jenkins 入口。 ## 8. 风险与边界 diff --git a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md index b0fc049d..90a2220f 100644 --- a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md +++ b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md @@ -2,6 +2,8 @@ 日期:`2026-04-29` +状态:历史方案。旧数据库导入导出 Jenkinsfile 已从仓库删除,生产版 `Genarrative-Database-Export` / `Genarrative-Database-Import` 已按 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 落地到 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`。本文只保留迁移脚本参数、权限边界和 `CHUNK_SIZE` 等经验;生产 Job 入口以生产部署计划和 `jenkins/Jenkinsfile.production-*` 为准。 + ## 1. 目标 为 Jenkins 增加两条人工触发的数据库迁移流水线: @@ -24,11 +26,7 @@ ### 3.1 数据库导出 -脚本路径: - -```text -jenkins/Jenkinsfile.database-export -``` +旧脚本路径:`jenkins/Jenkinsfile.database-export`。该文件当前仓库已删除;生产版入口为 `jenkins/Jenkinsfile.production-database-export`。 推荐作业名: @@ -38,28 +36,30 @@ Genarrative-Database-Export 关键参数: -1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。 -2. `SERVER`:SpacetimeDB server 别名,默认 `maincloud`。 -3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`。 -4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`。 -5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `/.spacetimedb`。 -6. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。 -7. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`。 -8. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-.json`。 +1. `DEPLOY_TARGET`:逻辑导出目标,`development` 映射到 `linux && genarrative-build`,`release` 映射到 `linux && genarrative-release-deploy`。 +2. `CONFIRM_RELEASE_DEPLOY_AGENT`:`DEPLOY_TARGET=release` 时必填确认。 +3. `SOURCE_BRANCH` / `COMMIT_HASH`:固定本次执行的迁移脚本版本。 +4. `DATABASE`:必填,目标 SpacetimeDB 数据库名。 +5. `SPACETIME_SERVER`:SpacetimeDB server 别名,默认 `local`。 +6. `SPACETIME_SERVER_URL`:显式服务地址;填写后优先于 `SPACETIME_SERVER`。 +7. `SPACETIME_ROOT_DIR`:`spacetime --root-dir`,默认 `/stdb`。 +8. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。 +9. `WORKSPACE_EXPORT_DIRECTORY`:Jenkins workspace 内导出目录,默认 `database-exports`。 +10. `SERVER_BACKUP_DIRECTORY`:可选,目标机器上的额外备份目录;留空则不保存服务器副本。 +11. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-.json`。 +12. `TOKEN_CREDENTIAL_ID`:可选,已授权迁移 operator token 的 Jenkins Secret Text 凭据 ID。 +13. `BOOTSTRAP_SECRET_CREDENTIAL_ID`:可选,迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID。 导出成功后,Jenkins 归档: ```text -/ +/ +/.sha256 ``` ### 3.2 数据库导入 -脚本路径: - -```text -jenkins/Jenkinsfile.database-import -``` +旧脚本路径:`jenkins/Jenkinsfile.database-import`。该文件当前仓库已删除;生产版入口为 `jenkins/Jenkinsfile.production-database-import`。 推荐作业名: @@ -69,25 +69,29 @@ Genarrative-Database-Import 关键参数: -1. `INPUT_FILE`:必填,迁移 JSON 文件路径。 -2. `DATABASE`、`SERVER`、`SERVER_URL`、`DEPLOY_DIRECTORY`、`ROOT_DIR`:与导出流水线一致。 -3. `INCLUDE_TABLES`:可选,只导入指定表。 -4. `CHUNK_SIZE`:迁移 JSON 分片大小,默认 `524288` bytes。导入脚本会在文件超过该大小或直接导入触发 HTTP 413 时自动分片上传。 -5. `DRY_RUN`:默认 `true`,只校验不写入。 -6. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。 -7. `REPLACE_EXISTING`:默认 `false`,只覆盖本次迁移文件中涉及的表;不可与 `INCREMENTAL` 同时启用。 -8. `BOOTSTRAP_SECRET`:可选,用于授权临时 Web API identity。 -9. `TOKEN`:可选,SpacetimeDB 客户端连接 token;留空时脚本会自动创建临时 identity 并在结束后撤销。 -10. `NOTE`:迁移授权备注。 +1. `INPUT_SOURCE`:必填,`pipeline_archive` 表示从 `Genarrative-Database-Export` 归档复制输入文件,`manual_upload` 表示本次构建手动上传数据源;两种方式互斥。 +2. `EXPORT_JOB_NAME` / `EXPORT_BUILD_NUMBER_TO_IMPORT` / `INPUT_FILE`:仅 `pipeline_archive` 模式使用。`EXPORT_JOB_NAME` 默认是导出流水线 `Genarrative-Database-Export`;`INPUT_FILE` 可留空,留空时按导出流水线默认归档路径解析为 `database-exports/spacetime-migration-<导出构建号>.json`。 +3. `MANUAL_INPUT_FILE`:仅 `manual_upload` 模式使用,Jenkins 通过 file parameter 接收本次构建上传文件。 +4. `DATABASE`、`SERVER`、`SERVER_URL`、`DEPLOY_DIRECTORY`、`ROOT_DIR`:与导出流水线一致。 +5. `INCLUDE_TABLES`:可选,只导入指定表。 +6. `CHUNK_SIZE`:迁移 JSON 分片大小,默认 `524288` bytes。导入脚本会在文件超过该大小或直接导入触发 HTTP 413 时自动分片上传。 +7. `DRY_RUN`:默认 `true`,只校验不写入。 +8. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。 +9. `REPLACE_EXISTING`:默认 `false`,只覆盖本次迁移文件中涉及的表;不可与 `INCREMENTAL` 同时启用。 +10. `BOOTSTRAP_SECRET`:可选,用于授权临时 Web API identity。 +11. `TOKEN`:可选,SpacetimeDB 客户端连接 token;留空时脚本会自动创建临时 identity 并在结束后撤销。 +12. `NOTE`:迁移授权备注。 ## 4. 安全边界 1. 导入流水线默认 `DRY_RUN=true`,需要人工明确关闭才会写入数据。 2. `INCREMENTAL` 与 `REPLACE_EXISTING` 互斥,Jenkinsfile 会在执行前阻止同时启用。 -3. Jenkinsfile 不打印 token;生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。 -4. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity,并调用迁移授权/撤销 procedure 收敛权限窗口。 -5. 导入导出流水线在调用仓库内迁移脚本前都会执行 `git reset --hard HEAD`,确保固定源码目录中的本地改动不会影响本次迁移操作。 -6. 如果日志出现 `SpacetimeDB HTTP 413: Failed to buffer the request body: length limit exceeded`,优先把 `CHUNK_SIZE` 调低到 `262144` 或更小后重跑。该参数只降低单次 HTTP body,不改变导入表范围。 +3. `INPUT_SOURCE=pipeline_archive` 时必须填写 `EXPORT_BUILD_NUMBER_TO_IMPORT`;`EXPORT_JOB_NAME` 默认使用导出流水线名称,`INPUT_FILE` 默认使用导出流水线默认归档路径,只有导出时自定义目录或文件名才需要显式填写。 +4. `INPUT_SOURCE=manual_upload` 时必须上传 `MANUAL_INPUT_FILE`,并把 `CONFIRM_INPUT_FILE` 填成原始文件名;`EXPORT_JOB_NAME` 的默认值可以保留,不参与该模式的输入边界。 +5. Jenkinsfile 不打印 token;生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。 +6. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity,并调用迁移授权/撤销 procedure 收敛权限窗口。 +7. 导入导出流水线在调用仓库内迁移脚本前都会执行 `git reset --hard HEAD`,确保固定源码目录中的本地改动不会影响本次迁移操作。 +8. 如果日志出现 `SpacetimeDB HTTP 413: Failed to buffer the request body: length limit exceeded`,优先把 `CHUNK_SIZE` 调低到 `262144` 或更小后重跑。该参数只降低单次 HTTP body,不改变导入表范围。 ## 5. 本地部署测试参数 @@ -112,7 +116,7 @@ DEPLOY_DIRECTORY=/var/lib/jenkins/deploy/Genarrative ## 6. 文件清单 ```text -jenkins/Jenkinsfile.database-export -jenkins/Jenkinsfile.database-import +jenkins/Jenkinsfile.production-database-export +jenkins/Jenkinsfile.production-database-import docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md ``` diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md new file mode 100644 index 00000000..efc656fa --- /dev/null +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -0,0 +1,518 @@ +# 生产部署计划 + +更新时间:2026-05-02 + +## 当前落地进度 + +已落地生产基础设施骨架与首批生产 Jenkinsfile: + +- `deploy/systemd/spacetimedb.service` +- `deploy/systemd/genarrative-api.service` +- `deploy/nginx/genarrative.conf` +- `deploy/nginx/snippets/genarrative-maintenance.conf` +- `deploy/env/api-server.env.example` +- `scripts/deploy/maintenance-on.sh` +- `scripts/deploy/maintenance-off.sh` +- `scripts/deploy/maintenance-status.sh` +- `scripts/build-production-release.sh` +- `scripts/jenkins-checkout-source.sh` +- `scripts/deploy/production-web-deploy.sh` +- `scripts/deploy/production-api-deploy.sh` +- `scripts/deploy/production-stdb-publish.sh` +- `jenkins/Jenkinsfile.production-web-build` +- `jenkins/Jenkinsfile.production-web-deploy` +- `jenkins/Jenkinsfile.production-api-build` +- `jenkins/Jenkinsfile.production-api-deploy` +- `jenkins/Jenkinsfile.production-stdb-module-build` +- `jenkins/Jenkinsfile.production-stdb-module-publish` +- `jenkins/Jenkinsfile.production-full-build-and-deploy` +- `jenkins/Jenkinsfile.production-server-provision` +- `jenkins/Jenkinsfile.production-database-export` +- `jenkins/Jenkinsfile.production-database-import` +- `npm run build:production-release` + +旧 Jenkins 一体化发布链对应的 Jenkinsfile 已从仓库移除,生产构建和发布入口统一切到 `jenkins/Jenkinsfile.production-*`。`scripts/deploy-rust-remote.sh` 等旧发布包脚本暂保留为历史迁移参考,不再作为生产 Jenkins Job 的入口。 + +## 目标 + +将当前部署方式调整为单机生产推荐方案:生产运行路径不使用 Docker,不再使用旧的一体化启动脚本,由 systemd 托管 SpacetimeDB 与 Rust `api-server`,由 Nginx 托管主站、后台前端与必要反向代理。 + +本计划用于重新创建 Jenkins 流水线、服务器环境配置、网站发布、`api-server` 发布、SpacetimeDB 模块发布,以及数据库人工导入导出流程。 + +## 生产架构 + +- Nginx 作为唯一公网入口,负责 HTTPS、静态站点、后台静态页面、维护页与 `/admin/api/` 反向代理。 +- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3000`,数据根目录为 `/stdb`。 +- Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。 +- 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。 +- 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。 + +## 服务器目录 + +- `/opt/genarrative/releases//`:每次发布的完整版本目录。 +- `/opt/genarrative/current`:指向当前生效版本的软链接。 +- `/srv/genarrative/web`:指向 `/opt/genarrative/current/web`,供 Nginx 托管静态站点。 +- `/etc/genarrative/api-server.env`:`api-server` 生产环境变量文件。 +- `/var/lib/genarrative/maintenance/enabled`:维护模式开关文件。 +- `/stdb`:SpacetimeDB 程序、配置与数据根目录。 + +## 生产密钥 + +`/etc/genarrative/api-server.env` 中的生产密钥指所有只能存在于生产服务器、不能进入 Git、不能进入构建产物的敏感配置。典型内容包括: + +- LLM 或第三方服务 API Key。 +- 短信服务 Access Key 与 Secret。 +- 后台登录、会话、签名、加密相关密钥。 +- 生产 SpacetimeDB 地址与数据库名。 +- 只允许生产使用的回调地址、白名单或内部令牌。 + +该文件由服务器配置流水线或人工初始化创建,权限建议为 `root:genarrative`、`0640`。Jenkins 构建任务不能读取该文件;只有生产发布或服务启动需要读取。 + +## systemd 服务 + +### SpacetimeDB + +- 服务名:`spacetimedb.service` +- 运行用户:`spacetimedb` +- 工作目录:`/stdb` +- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000` +- 对外暴露:默认不直接暴露公网端口。 + +该方案与 SpacetimeDB 官方自托管文档一致:使用 Ubuntu、专用用户、`/stdb` 根目录、systemd 服务和 Nginx。 + +### api-server + +- 服务名:`genarrative-api.service` +- 运行用户:`genarrative` +- 工作目录:`/opt/genarrative/current` +- 可执行文件:`/opt/genarrative/current/api-server` +- 环境文件:`/etc/genarrative/api-server.env` +- 监听地址:`127.0.0.1:8082` + +`api-server` 不放入 Docker,也不直接暴露公网端口。发布时替换版本目录并重启 `genarrative-api.service`。 + +## Nginx 规则 + +只保留必要入口: + +- `/`:主站静态页面。 +- `/admin/`:后台前端静态页面,后台构建时使用 `/admin/` 作为 base path。 +- `/admin/api/`:反向代理到 `http://127.0.0.1:8082/admin/api/`。 +- HTTP 到 HTTPS:只保留 301 重定向。 +- `/maintenance.html`:维护中页面。 + +移除这些公网反向代理: + +- `/api/*` +- `/generated-*` +- 公网 `/healthz` +- 其他旧的一体化 web server 代理入口。 + +SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。 + +## 维护模式 + +维护模式由 `/var/lib/genarrative/maintenance/enabled` 控制: + +- 文件存在:进入维护模式。 +- 文件不存在:退出维护模式。 + +行为: + +- 网站静态资源发布不进入维护模式。 +- `api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。 +- 普通页面在维护模式下展示 `/maintenance.html`。 +- `/admin/api/*` 在维护模式下返回 503。 +- 静态资源仍允许访问,避免维护页样式和资源加载失败。 +- 发布成功后自动解除维护模式。 +- 发布失败时保持维护模式,并通过邮件通知人工处理。 + +## 构建产物 + +生产发布包构建入口: + +```bash +npm run build:production-release -- --name +``` + +每次构建产物按版本号归档: + +```text +build// +├─ web/ +│ ├─ index.html +│ ├─ assets/ +│ ├─ maintenance.html +│ └─ admin/ +├─ web.tar.gz +├─ web.tar.gz.sha256 +├─ api-server +├─ api-server.sha256 +├─ spacetime_module.wasm +├─ spacetime_module.wasm.sha256 +├─ release-manifest.json +├─ scripts/ +│ ├─ database-export.mjs +│ ├─ database-import.mjs +│ ├─ spacetime-migration-common.mjs +│ ├─ maintenance-on.sh +│ ├─ maintenance-off.sh +│ └─ maintenance-status.sh +├─ deploy/ +│ ├─ systemd/ +│ │ ├─ spacetimedb.service +│ │ └─ genarrative-api.service +│ ├─ nginx/ +│ │ ├─ genarrative.conf +│ │ └─ snippets/genarrative-maintenance.conf +│ └─ env/api-server.env.example +└─ README.md +``` + +`web/` 可以保留在构建目录中供本地 smoke test 与人工排查使用,但 Jenkins Web Build 归档和 Web Deploy 传输必须以 `web.tar.gz` 为主,避免把大量静态碎文件逐个传回 Jenkins controller。`api-server` 和 `spacetime_module.wasm` 是单文件产物,默认直接归档单文件与对应 `.sha256`,不强制压缩。 + +不再生成旧产物: + +- `web-server.mjs` +- 旧的一体化 `start.sh` +- 旧的一体化 `stop.sh` + +## Jenkins 节点 + +Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工触发入口;构建与发布动作只允许由 Jenkins 调度到 Linux agent 执行。当前已接入的 Linux agent 是开发/构建机,同时也是 development 环境部署机。 + +### 开发/构建/开发部署实例 + +- Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。 +- 构建 Job 固定使用 label expression:`linux && genarrative-build`。 +- 当前开发/构建/开发部署 agent 必须同时配置 `linux` 与 `genarrative-build` 两个标签;非 Linux 节点不能承担构建或部署。 +- 用途:拉代码、安装依赖、构建主站、构建后台、构建 `api-server`、构建 SpacetimeDB wasm、归档产物,并执行 `DEPLOY_TARGET=development` 的开发环境部署。 + +### 生产/发布实例 + +- Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。 +- 生产机已作为独立 Linux Jenkins agent 接入,节点名使用脱敏名称 `genarrative-release-deploy-01`,调度标签只使用 `linux` 与 `genarrative-release-deploy`。 +- 生产机真实连接地址只允许保存在 Jenkins 节点 SSH launcher 的 `host` 字段中,不能写入节点名、调度标签、Job 参数默认值或文档推荐命令。 +- 发布 Job 通过 `DEPLOY_TARGET` 选择逻辑部署目标,再在 Jenkinsfile 内部映射到 Linux-only 脱敏调度表达式:`development -> linux && genarrative-build`,`release -> linux && genarrative-release-deploy`。 +- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 + +### Git 仓库访问 + +Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: + +- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。 +- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 + +因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`。后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 + +`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,必须把对应 Jenkinsfile 的 `GIT_REMOTE_URL` 改成 release agent 可访问的内网地址,不能让 release 发布阶段回退到 controller 公网拉取。 + +### SSH PEM 凭证 + +在 Jenkins 中使用 `SSH Username with private key` 类型添加 PEM 私钥: + +- `genarrative-dev-ssh-key`:开发/构建实例 SSH 凭证。 +- `genarrative-prod-root-ssh`:当前开发/构建实例已使用的 SSH 凭证;生产/发布实例复用同一个凭证。 + +推荐使用非 root 用户,例如 `jenkins`。该用户只通过 sudoers 获得必要命令权限,例如 `systemctl restart genarrative-api`、`nginx -t`、维护脚本、发布目录切换等。 + +## Jenkins 流水线 + +生产 Jenkins 目标流水线: + +1. `Genarrative-Server-Provision` +2. `Genarrative-Web-Build` +3. `Genarrative-Web-Deploy` +4. `Genarrative-Api-Build` +5. `Genarrative-Api-Deploy` +6. `Genarrative-Stdb-Module-Build` +7. `Genarrative-Stdb-Module-Publish` +8. `Genarrative-Database-Export` +9. `Genarrative-Database-Import` +10. `Genarrative-Full-Build-And-Deploy` + +已落地的生产流水线脚本文件: + +- `jenkins/Jenkinsfile.production-web-build` +- `jenkins/Jenkinsfile.production-web-deploy` +- `jenkins/Jenkinsfile.production-api-build` +- `jenkins/Jenkinsfile.production-api-deploy` +- `jenkins/Jenkinsfile.production-stdb-module-build` +- `jenkins/Jenkinsfile.production-stdb-module-publish` +- `jenkins/Jenkinsfile.production-full-build-and-deploy` +- `jenkins/Jenkinsfile.production-server-provision` +- `jenkins/Jenkinsfile.production-database-export` +- `jenkins/Jenkinsfile.production-database-import` + +`Genarrative-Database-Export`、`Genarrative-Database-Import` 的生产版 Jenkinsfile 已落地;旧的数据库导入导出 Jenkinsfile 已删除,避免继续沿用旧部署目录和旧一体化发布链假设。 + +构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 + +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 + +所有发布流水线必须提供 `DEPLOY_TARGET` 参数,用于选择逻辑部署目标: + +- 默认值:`development`,用于当前 Linux 开发/构建/开发部署 agent 上的开发环境部署,对应脱敏调度表达式 `linux && genarrative-build`。 +- 备选值:`release`,对应生产发布目标,由独立 Linux 生产部署 agent 执行。 + +发布流水线的 `agent` 必须使用 `DEPLOY_TARGET` 在 Jenkinsfile 内部映射到脱敏 label expression,并且表达式必须包含 `linux`,避免在参数页面暴露真实节点名、IP 或带 IP 的 Jenkins label,也避免非 Linux 节点执行构建或部署。`release` 目标还必须要求 `CONFIRM_RELEASE_DEPLOY_AGENT=true`。`Genarrative-Full-Build-And-Deploy` 也必须透传同一个 `DEPLOY_TARGET`,确保 Stdb publish、API deploy、Web deploy 三个部署动作落到同一类目标部署环境。 + +### Rust 构建缓存与磁盘控制 + +Jenkins 在 agent 上执行构建时会为不同 Job 或不同构建创建独立 workspace;Rust 默认把编译产物写入仓库内 `server-rs/target/`,会导致每个 workspace 都保留一份完整 target 目录,占用大量磁盘。生产构建流水线必须把 Rust 缓存固定到 workspace 外的稳定目录。 + +如果构建 agent 使用 `root` 账户执行,缓存目录不应写死为 `/var/lib/jenkins`。推荐优先使用单独数据盘,例如 `/data/jenkins-cache/genarrative/`;如果没有数据盘,可使用 `/var/cache/genarrative-build/`: + +```bash +mkdir -p /var/cache/genarrative-build/{cargo-home,cargo-target,sccache} +chmod 700 /var/cache/genarrative-build +``` + +Rust 构建流水线建议统一设置: + +```groovy +environment { + CARGO_HOME = '/var/cache/genarrative-build/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_INCREMENTAL = '0' + + RUSTC_WRAPPER = 'sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_CACHE_SIZE = '30G' +} +``` + +如使用数据盘,则把上述路径替换为: + +```groovy +environment { + CARGO_HOME = '/data/jenkins-cache/genarrative/cargo-home' + CARGO_TARGET_DIR = '/data/jenkins-cache/genarrative/cargo-target/prod-release' + CARGO_INCREMENTAL = '0' + + RUSTC_WRAPPER = 'sccache' + SCCACHE_DIR = '/data/jenkins-cache/genarrative/sccache' + SCCACHE_CACHE_SIZE = '30G' +} +``` + +`scripts/build-production-release.sh` 必须尊重 `CARGO_TARGET_DIR`,不能硬编码从 `server-rs/target/` 拷贝 Rust 产物。脚本中的产物路径应按以下口径计算: + +```bash +CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${SERVER_RS_DIR}/target}" +API_BINARY_SOURCE="${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/api-server" +WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module.wasm" +``` + +并发与清理规则: + +- Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,或对共享 `CARGO_TARGET_DIR` 加 Jenkins `lock`,避免多个同包 release 构建同时写入同一最终产物路径。 +- 生产发布流水线只能消费 `build//` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 +- `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 +- 对 `/var/cache/genarrative-build/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 +- Jenkins Job 必须配置构建记录和归档产物保留策略,避免历史 release 包长期堆积。 + +### 统一源码版本参数 + +所有构建流水线、发布流水线和 `Genarrative-Full-Build-And-Deploy` 都必须支持以下参数: + +- `SOURCE_BRANCH`:源码分支,默认 `master`,代表 `origin/master` 最新提交。 +- `COMMIT_HASH`:可选 Git commit hash,留空时使用 `origin/` 最新 commit;填写时必须是 7 到 40 位十六进制 hash,并且该 commit 必须属于 `origin/`。 +- `DEPLOY_TARGET`:逻辑部署目标选择参数。发布流水线和 `Genarrative-Full-Build-And-Deploy` 必填;构建流水线仅在 `PUBLISH_AFTER_BUILD=true` 时用于触发下游发布。 + +执行规则: + +- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行 `git fetch --tags --prune origin "+refs/heads/:refs/remotes/origin/"`。 +- 如果工作区是浅克隆,流水线必须尝试 `git fetch --unshallow --tags`,确保能验证目标 commit 与分支关系。 +- `COMMIT_HASH` 为空时,detached checkout 到 `refs/remotes/origin/` 当前最新 commit。 +- `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor refs/remotes/origin/` 校验该提交属于指定分支,校验通过后 detached checkout。 +- 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`。 +- 构建产物必须写入 `release-manifest.json`,至少包含 `version`、`source_branch`、`source_commit`、`built_at` 和组件类型,供发布、回滚和审计使用。 + +构建流水线使用上述参数决定实际构建源码。发布流水线也暴露同名参数,但只用于选择本次发布使用的部署脚本、配置模板和 smoke test 逻辑;被发布的应用文件仍必须来自 Jenkins 归档产物或指定 release 包,不允许在发布流水线中重新构建。 + +当构建流水线以 `PUBLISH_AFTER_BUILD=true` 触发下游发布流水线时,必须把 `SOURCE_BRANCH` 和实际解析出的 `SOURCE_COMMIT` 作为下游 `COMMIT_HASH` 传递,确保部署逻辑和刚生成的产物可追溯到同一源码版本。 + +构建流水线支持参数 `PUBLISH_AFTER_BUILD`: + +- `false`:只构建并归档产物。 +- `true`:构建成功后触发对应发布流水线。 + +发布流水线必须从归档产物获取文件,不依赖构建 workspace 的本地状态。 + +## 流水线职责 + +### Genarrative-Server-Provision + +用于生产服务器一次性或低频配置: + +- 创建 `spacetimedb`、`genarrative` 等系统用户。 +- 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 +- 安装或更新 SpacetimeDB。 +- 安装 systemd unit。 +- 安装 Nginx 配置和维护模式 snippet。 +- 执行 `nginx -t`。 +- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 + +该流水线属于高风险操作,默认要求人工确认后执行。 +已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、Nginx 配置并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 + +### Web Build / Deploy + +构建: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。 +- 构建主站静态文件。 +- 构建后台前端,base path 为 `/admin/`。 +- 生成或复制 `maintenance.html`。 +- 将 `web/` 打包为 `web.tar.gz`,生成 `web.tar.gz.sha256`。 +- 归档 `web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json`;`web/` 展开目录不作为 Jenkins 主归档对象。 + +发布: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 +- 获取 `web.tar.gz` 与 `web.tar.gz.sha256`,先校验 checksum,再解压到 `/opt/genarrative/releases//web`。 +- 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 +- 执行 Nginx 配置测试和静态页面 smoke test。 +- 不进入维护模式。 + +### Api Build / Deploy + +构建: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。 +- 编译 Rust `api-server`。 +- 归档单一可执行文件、必要运行说明和 `release-manifest.json`。 + +发布: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 +- 进入维护模式。 +- 解包到 `/opt/genarrative/releases//api-server`。 +- 更新 `/opt/genarrative/current`。 +- 重启 `genarrative-api.service`。 +- 检查本机 `/healthz`。 +- 导出产物归档成功后解除维护模式。 +- 失败时保留维护模式并发邮件。 + +### Stdb Module Build / Publish + +构建: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。 +- 使用 `spacetime build` 构建 `spacetime_module.wasm`。 +- 归档 wasm、发布脚本和 `release-manifest.json`。 + +发布: + +- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 发布脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 +- 进入维护模式。 +- 将 wasm 上传到生产实例。 +- 在生产实例本机执行 `spacetime publish -s local --bin-path spacetime_module.wasm `。 +- 成功后执行必要 smoke test。 +- 成功后解除维护模式。 +- 失败时保留维护模式并发邮件。 + +### Full Build-And-Deploy + +- 先解析一次最终 `SOURCE_COMMIT`,所有下游构建和发布都使用同一个分支与 commit。 +- 并行执行 Web / API / Stdb 三条构建流水线。 +- 构建全部成功后,按顺序执行 Stdb publish、API deploy、Web deploy,并把同一个 `DEPLOY_TARGET` 透传给三条发布流水线。 +- 每条下游构建都只消费自己的归档产物,不直接复用别的 workspace。 +- 生产 Web 发布只处理 `web.tar.gz` 与 checksum,API 发布只处理 `api-server` 与 checksum,Stdb 发布只处理 `spacetime_module.wasm` 与 checksum。 + +## 数据库导出与导入 + +### 导出 + +`Genarrative-Database-Export` 用于人工导出生产数据: + +- 已落地 Jenkinsfile:`jenkins/Jenkinsfile.production-database-export`。 +- 通过 `DEPLOY_TARGET` 选择逻辑导出目标;`development` 映射到 `linux && genarrative-build`,`release` 映射到 `linux && genarrative-release-deploy`。 +- `release` 导出必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,避免当前开发/构建/开发部署 agent 冒充 release 部署机。 +- 进入维护模式,避免导出期间继续写入。 +- 从目标机器本机 SpacetimeDB 导出指定数据库数据,默认连接 `SPACETIME_SERVER=local`,自托管 `root-dir` 默认 `/stdb`。 +- 产物归档到 Jenkins,并可额外保存到 `SERVER_BACKUP_DIRECTORY`。 +- 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。 +- 成功后解除维护模式。 +- 失败时保留维护模式并邮件通知。 + +### 导入 + +`Genarrative-Database-Import` 用于人工导入或恢复数据: + +- 已落地 Jenkinsfile:`jenkins/Jenkinsfile.production-database-import`。 +- 通过 `DEPLOY_TARGET` 选择逻辑导入目标;`development` 映射到 `linux && genarrative-build`,`release` 映射到 `linux && genarrative-release-deploy`。 +- `release` 导入必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,避免当前开发/构建/开发部署 agent 冒充 release 部署机。 +- 通过 `INPUT_SOURCE` 选择数据源,`pipeline_archive` 从 `Genarrative-Database-Export` 归档复制 `INPUT_FILE`,`manual_upload` 使用本次构建上传的 `MANUAL_INPUT_FILE`;两种方式互斥,`Prepare` 阶段会直接拦截混填。 +- `DRY_RUN` 默认开启;真正写入数据时必须勾选 `CONFIRM_IMPORT`,并让 `CONFIRM_DATABASE` 与 `CONFIRM_INPUT_FILE` 分别完全匹配 `DATABASE` 与实际输入文件。 +- `REPLACE_EXISTING=true` 且 `DRY_RUN=false` 时必须额外勾选 `CONFIRM_REPLACE_EXISTING`。 +- 进入维护模式。 +- 导入前先生成一次安全备份。 +- 执行导入。 +- 执行数据校验和服务 smoke test。 +- 成功后解除维护模式。 +- 失败时保留维护模式并邮件通知。 +- `pipeline_archive` 模式默认使用导出流水线 `Genarrative-Database-Export`;只需要填写 `EXPORT_BUILD_NUMBER_TO_IMPORT` 时,归档输入路径自动解析为 `database-exports/spacetime-migration-<导出构建号>.json`。如果导出时覆盖过 `WORKSPACE_EXPORT_DIRECTORY` 或 `EXPORT_NAME`,再显式填写归档内相对路径 `INPUT_FILE`。 +- `manual_upload` 模式需要上传 `MANUAL_INPUT_FILE`,并在 `CONFIRM_INPUT_FILE` 中填写原始文件名;此模式不再填写 `EXPORT_BUILD_NUMBER_TO_IMPORT` 或 `INPUT_FILE`,`EXPORT_JOB_NAME` 的默认值会被忽略。 +- 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。 + +数据库表结构变更必须同步检查并更新 `migration.rs`,不能只发布 wasm。 + +## 全量构建并发布 + +`Genarrative-Full-Build-And-Deploy` 编排: + +1. 按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析一次最终 `SOURCE_COMMIT`,默认 `origin/master` 最新 commit。 +2. 并行触发 `Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build`,三条构建都必须使用同一个 `SOURCE_BRANCH` 和 `SOURCE_COMMIT`。 +3. 三条构建全部成功后,按顺序触发 `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy`,同样继续透传同一个 `SOURCE_BRANCH`、`SOURCE_COMMIT` 和 `DEPLOY_TARGET`。 +4. 最后执行生产 smoke test。 + +网站最后发布,避免后台或主站提前指向尚未完成发布的后端能力。 + +这种编排能够在不牺牲版本一致性的前提下缩短总耗时,同时避免 Web / API / Stdb 彼此等待构建资源。并行构建时仍要保证每条构建流水线独立归档自己的产物,发布阶段只能消费归档结果,不能回到构建 workspace 重新取文件。 + +`Genarrative-Full-Build-And-Deploy` 额外作为定时任务运行,Jenkins 在每天凌晨 4 点自动触发一次,默认按 `SOURCE_BRANCH=master` 解析最新提交,并以 `DEPLOY_TARGET=development` 把全量构建结果部署到开发机。该定时任务不改写手动触发参数语义,只是给主线全量发布补一个固定的夜间全量刷新入口。 + +## 回滚 + +- 网站回滚:将 `/srv/genarrative/web` 或 `/opt/genarrative/current` 切回上一版本并 reload Nginx。 +- `api-server` 回滚:将 `/opt/genarrative/current` 切回上一版本并重启 `genarrative-api.service`。 +- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。 +- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。 + +## 待落地文件 + +后续工程落地时需要新增或改造: + +- [x] `deploy/systemd/spacetimedb.service` +- [x] `deploy/systemd/genarrative-api.service` +- [x] `deploy/nginx/genarrative.conf` +- [x] `deploy/nginx/snippets/genarrative-maintenance.conf` +- [x] `deploy/env/api-server.env.example` +- [x] `scripts/deploy/maintenance-on.sh` +- [x] `scripts/deploy/maintenance-off.sh` +- [x] `scripts/deploy/maintenance-status.sh` +- [x] `scripts/build-production-release.sh` +- [x] `scripts/jenkins-checkout-source.sh` +- [x] `scripts/deploy/production-web-deploy.sh` +- [x] `scripts/deploy/production-api-deploy.sh` +- [x] `scripts/deploy/production-stdb-publish.sh` +- [x] `jenkins/Jenkinsfile.production-web-build` +- [x] `jenkins/Jenkinsfile.production-web-deploy` +- [x] `jenkins/Jenkinsfile.production-api-build` +- [x] `jenkins/Jenkinsfile.production-api-deploy` +- [x] `jenkins/Jenkinsfile.production-stdb-module-build` +- [x] `jenkins/Jenkinsfile.production-stdb-module-publish` +- [x] `jenkins/Jenkinsfile.production-full-build-and-deploy` +- [x] `jenkins/Jenkinsfile.production-server-provision` +- [x] 删除旧 Jenkinsfile:`Jenkinsfile.build-and-deploy`、`Jenkinsfile.deploy`、`Jenkinsfile.database-export`、`Jenkinsfile.database-import` +- [x] 更新旧部署文档,标记旧一体化脚本为废弃或迁移对象。 +- [x] `jenkins/Jenkinsfile.production-database-export` +- [x] `jenkins/Jenkinsfile.production-database-import` + +## 参考 + +- SpacetimeDB 官方自托管文档:https://spacetimedb.com/docs/how-to/deploy/self-hosting/ + - 该文档建议 Ubuntu 24.04、`spacetimedb` 专用用户、`/stdb` root-dir、systemd 托管,以及 Nginx/HTTPS。 + - 默认公网路由只开放 TypeScript SDK 必需的 `/v1/identity` 与 `/v1/database//subscribe`,其他发布、SQL、管理类入口保持本机可用。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 07c3d8fb..a9a3ec44 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md](./PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md):冻结当前对外中文命名,产品展示名统一为“百梦”,消费单位为“光点”,公开账号标识为“百梦号”,创作侧称谓为“百梦主”。 +- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 - [AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md](./AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md):记录 Maincloud `xushi-p4wfr` 挂起导致认证快照同步和抓大鹅创作失败的根因、认证同步非阻断修复、`/api/creation` Vite 代理补齐和本地 SpacetimeDB 可跑链路。 @@ -19,7 +20,7 @@ - [MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md](./MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md):记录抓大鹅 Match3D 第一至第三波完成度复核、Q1 主链集成落点、定向验收命令和遗留风险。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 -- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 +- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):历史记录,保留 `Genarrative-Database-Export` / `Genarrative-Database-Import` 迁移参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避经验;旧 Jenkinsfile 已删除,生产版入口以 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 为准。 - [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md):冻结后台管理独立前端工程 `apps/admin-web` 的技术方案,明确管理端只做表现、全部数据和写操作走 `server-rs` 的 `/admin/api/*`,并接管旧 `GET /admin` 内嵌页面的 UI 职责。 - [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client,角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`。 - [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 @@ -82,9 +83,9 @@ - [CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md](./CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md):记录发布阻断项仍按旧 `worldHook / playerPremise / sceneChapters` schema 校验的问题,以及将 Rust `publish gate` 对齐到 `anchorContent / creatorIntent / sceneChapterBlueprints` 当前主链结构的修复口径。 - [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。 - [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。 -- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌能力。 +- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):历史记录,保留旧 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌经验;旧 Jenkinsfile 已删除,生产版入口以 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 为准。 - [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。 -- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传、安全清库开关,以及发布包内 schema 冲突自动迁移脚本。 +- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):历史记录,保留 Rust 本地一键联调脚本与旧 Ubuntu 发布包构建脚本的执行口径;生产发布链路以 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 的 systemd、Nginx、生产 Jenkinsfile 与归档产物拆分为准。 - [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 Axum 路由,并补充管理 API 索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。 - [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。 - [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。 @@ -208,11 +209,11 @@ - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md):AI 生成角色形象与角色动画的技术路线。 - [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。 - [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md):PixelMotion 产品形态与能力拆解。 -- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。 +- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):旧服务端部署、代理层与 CORS 方案,保留同源代理和密钥不进前端的背景判断;当前生产拓扑和发布入口以 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 为准。 - [WORLD_DRAFT_FOUNDATION_EDITOR_TARGET_FIX_2026-04-25.md](./WORLD_DRAFT_FOUNDATION_EDITOR_TARGET_FIX_2026-04-25.md):记录世界草稿“基本设定”独立编辑目标、分号标签化展示与编辑回写边界。 ## 使用建议 -- 做实现选型时,优先看这一组。 +- 做实现选型时,优先看这一组;做生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`,再按需追溯旧 Jenkins / CORS / 本地发布脚本文档。 - 做后端实现前,先看 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`;涉及 SpacetimeDB 表结构、发布或迁移时,再看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`,最后进入具体 Rust / SpacetimeDB 方案。 - 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 2dc49f6b..de86fa9a 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -2,6 +2,8 @@ 日期:`2026-04-22` +状态:本地联调口径仍可参考;远端发布包和旧一体化启动脚本属于历史方案。生产发布、生产 Jenkins Job、systemd/Nginx 拓扑和 release/current 目录规则以 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md) 为准。 + ## 1. 目标 本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本: diff --git a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md index 2f2ac8bc..c25afff7 100644 --- a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md +++ b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md @@ -2,6 +2,8 @@ 日期:`2026-04-05` +状态:历史部署背景文档。本文保留同源代理、密钥不进入前端、CORS 不作为主解法等判断;当前生产拓扑、systemd/Nginx 配置和 Jenkins 发布入口以 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md) 为准。 + ## 1. 文档目标 本文要解决的不只是“当前部署到服务器后浏览器报 CORS”,而是同时解决下面几类问题: diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy deleted file mode 100644 index ee82db1e..00000000 --- a/jenkins/Jenkinsfile.build-and-deploy +++ /dev/null @@ -1,202 +0,0 @@ -pipeline { - agent none - - options { - disableConcurrentBuilds() - timestamps() - } - - environment { - GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" - PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - } - - parameters { - string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') - string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') - string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定要构建的 Git commit hash;留空则使用 SCM 当前检出的提交') - string(name: 'DATABASE', defaultValue: 'genarrative-pipeline-local-test', description: '发布包默认 SpacetimeDB database') - string(name: 'API_PORT', defaultValue: '8082', description: '发布包内 api-server 端口') - string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001') - string(name: 'SPACETIME_PORT', defaultValue: '3101', description: '发布包内本地 SpacetimeDB 端口') - booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') - booleanParam(name: 'MIGRATE_ON_CONFLICT', defaultValue: true, description: '普通发布遇到 SpacetimeDB schema 冲突时自动导出、清库发布并导入回灌') - string(name: 'MIGRATION_DIRECTORY', defaultValue: '', description: '自动迁移 JSON 输出目录,留空则使用部署目录内 database-migrations/') - password(name: 'MIGRATION_EXPORT_TOKEN', defaultValue: '', description: '可选,旧库已授权迁移操作员 token,仅部署阶段用于 schema 冲突导出') - password(name: 'MIGRATION_IMPORT_TOKEN', defaultValue: '', description: '可选,新库已授权迁移操作员 token,仅部署阶段用于 schema 冲突导入') - booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') - string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名') - string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录') - booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', defaultValue: true, description: 'start.sh / stop.sh 是否通过 sudo -n 执行') - } - - stages { - stage('构建发布包') { - agent { - label "${params.AGENT_LABEL}" - } - - steps { - script { - // 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。 - env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER - // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 - env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() - def commitHash = params.COMMIT_HASH?.trim() - if (commitHash && !(commitHash ==~ /^[0-9a-fA-F]{7,40}$/)) { - error('COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash,当前值: ' + commitHash) - } - env.COMMIT_HASH = commitHash ?: '' - def database = params.DATABASE?.trim() - if (!database) { - error('DATABASE 不能为空。') - } - if (!(database ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) { - error('DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔,当前值: ' + database) - } - env.EFFECTIVE_DATABASE = database - echo "SpacetimeDB 发布数据库: ${env.EFFECTIVE_DATABASE}" - def apiPort = params.API_PORT?.trim() - if (!apiPort) { - error('API_PORT 不能为空。') - } - if (!(apiPort ==~ /^[0-9]+$/)) { - error("API_PORT 必须是数字端口,当前值: ${apiPort}") - } - if (apiPort.length() > 5) { - error("API_PORT 必须在 1-65535 之间,当前值: ${apiPort}") - } - def parsedApiPort = apiPort.toInteger() - if (parsedApiPort < 1 || parsedApiPort > 65535) { - error("API_PORT 必须在 1-65535 之间,当前值: ${apiPort}") - } - env.EFFECTIVE_API_PORT = apiPort - def webPort = params.WEB_PORT?.trim() - if (!webPort) { - error('WEB_PORT 不能为空。') - } - if (!(webPort ==~ /^[0-9]+$/)) { - error("WEB_PORT 必须是数字端口,当前值: ${webPort}") - } - if (webPort.length() > 5) { - error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") - } - def parsedWebPort = webPort.toInteger() - if (parsedWebPort < 1 || parsedWebPort > 65535) { - error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") - } - // 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。 - env.EFFECTIVE_WEB_PORT = webPort - def spacetimePort = params.SPACETIME_PORT?.trim() - if (!spacetimePort) { - error('SPACETIME_PORT 不能为空。') - } - if (!(spacetimePort ==~ /^[0-9]+$/)) { - error("SPACETIME_PORT 必须是数字端口,当前值: ${spacetimePort}") - } - if (spacetimePort.length() > 5) { - error("SPACETIME_PORT 必须在 1-65535 之间,当前值: ${spacetimePort}") - } - def parsedSpacetimePort = spacetimePort.toInteger() - if (parsedSpacetimePort < 1 || parsedSpacetimePort > 65535) { - error("SPACETIME_PORT 必须在 1-65535 之间,当前值: ${spacetimePort}") - } - env.EFFECTIVE_SPACETIME_PORT = spacetimePort - // 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。 - env.SOURCE_NODE_NAME = env.NODE_NAME - } - - dir("${env.WORKSPACE_ROOT}") { - checkout scm - - sh ''' - bash -lc ' - set -euo pipefail - # 每条流水线开头先强制回到 SCM 检出的干净提交,避免固定源码目录残留改动影响本次执行。 - git reset --hard HEAD - requested_commit="${COMMIT_HASH:-}" - if [[ -n "${requested_commit}" ]]; then - # Jenkins 先按 SCM 配置完成 checkout;如指定 commit,再拉取远端引用并切到该提交构建。 - git fetch --tags --prune origin "+refs/heads/*:refs/remotes/origin/*" || git fetch --all --tags --prune - if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then - git fetch --unshallow --tags || true - fi - git cat-file -e "${requested_commit}^{commit}" - resolved_commit="$(git rev-parse "${requested_commit}^{commit}")" - git checkout --detach "${resolved_commit}" - echo "[build-and-deploy] 使用指定 commit 构建: ${resolved_commit}" - else - resolved_commit="$(git rev-parse HEAD)" - echo "[build-and-deploy] 使用 SCM checkout commit 构建: ${resolved_commit}" - fi - # 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。 - # 这里不使用 -x,避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 - git reset --hard HEAD - git clean -fd - echo "${resolved_commit}" > "build-and-deploy-commit.txt" - rm -rf "build/${EFFECTIVE_BUILD_VERSION}" - ' - ''' - - script { - env.EFFECTIVE_COMMIT_HASH = readFile('build-and-deploy-commit.txt').trim() - echo "构建 commit: ${env.EFFECTIVE_COMMIT_HASH}" - } - - script { - // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 - if (params.RUN_NPM_CI) { - sh 'bash -lc "npm ci"' - } - } - - sh """ - bash -lc ' - set -euo pipefail - # 构建并部署流水线显式透传本地测试参数,避免发布包回退到默认库名或端口。 - npm run deploy:rust:remote -- --skip-upload \ - --name "${env.EFFECTIVE_BUILD_VERSION}" \ - --database "${env.EFFECTIVE_DATABASE}" \ - --api-port "${env.EFFECTIVE_API_PORT}" \ - --web-port "${env.EFFECTIVE_WEB_PORT}" \ - --spacetime-port "${env.EFFECTIVE_SPACETIME_PORT}" - test -d "build/${env.EFFECTIVE_BUILD_VERSION}" - ' - """ - - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true - } - } - } - - stage('触发部署') { - steps { - // 本阶段没有声明 agent,确保触发下游前已经释放构建节点,避免单执行器死锁。 - build job: params.DEPLOY_JOB_NAME, - wait: true, - propagate: true, - parameters: [ - string(name: 'SOURCE_NODE_NAME', value: env.SOURCE_NODE_NAME), - string(name: 'SOURCE_WORKSPACE_ROOT', value: env.WORKSPACE_ROOT), - string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), - string(name: 'DEPLOY_DIRECTORY', value: params.DEPLOY_DIRECTORY), - string(name: 'WEB_PORT', value: env.EFFECTIVE_WEB_PORT), - booleanParam(name: 'CLEAR_DATABASE', value: params.CLEAR_DATABASE), - booleanParam(name: 'MIGRATE_ON_CONFLICT', value: params.MIGRATE_ON_CONFLICT), - string(name: 'MIGRATION_DIRECTORY', value: params.MIGRATION_DIRECTORY), - password(name: 'MIGRATION_EXPORT_TOKEN', value: params.MIGRATION_EXPORT_TOKEN), - password(name: 'MIGRATION_IMPORT_TOKEN', value: params.MIGRATION_IMPORT_TOKEN), - booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', value: params.RUN_DEPLOY_HOOKS_WITH_SUDO), - string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME), - ] - } - } - } - - post { - success { - echo "构建并部署完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}" - } - } -} diff --git a/jenkins/Jenkinsfile.database-export b/jenkins/Jenkinsfile.database-export deleted file mode 100644 index 808416d6..00000000 --- a/jenkins/Jenkinsfile.database-export +++ /dev/null @@ -1,103 +0,0 @@ -pipeline { - agent none - - options { - disableConcurrentBuilds() - timestamps() - } - - environment { - GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" - PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - } - - parameters { - string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') - string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') - string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') - string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') - string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL,填写后优先于 SERVER') - string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录,ROOT_DIR 为空时使用其 .spacetimedb') - string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir,可选,优先于 DEPLOY_DIRECTORY') - string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') - string(name: 'BOOTSTRAP_SECRET', defaultValue: '', description: '可选,授权临时导出 identity 的迁移引导密钥') - string(name: 'OUTPUT_DIRECTORY', defaultValue: 'database-exports', description: '导出文件目录,相对源码根目录或绝对路径') - string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则自动使用构建号') - } - - stages { - stage('导出数据库') { - agent { - label "${params.AGENT_LABEL}" - } - - steps { - script { - // 允许 Jenkins Job 指定固定源码目录;未指定时使用当前工作区,方便临时手工执行。 - env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() - def deployDirectory = params.DEPLOY_DIRECTORY?.trim() - if (!deployDirectory) { - error('DEPLOY_DIRECTORY 不能为空。') - } - env.EFFECTIVE_ROOT_DIR = params.ROOT_DIR?.trim() ? params.ROOT_DIR.trim() : "${deployDirectory}/.spacetimedb" - def exportName = params.EXPORT_NAME?.trim() - if (!exportName) { - exportName = "spacetime-migration-${env.BUILD_NUMBER}.json" - } - if (!(exportName ==~ /^[A-Za-z0-9._-]+$/)) { - error("EXPORT_NAME 只能包含字母、数字、点、下划线和短横线,当前值: ${exportName}") - } - env.EFFECTIVE_EXPORT_NAME = exportName - } - - dir("${env.WORKSPACE_ROOT}") { - checkout scm - - sh """ - bash -lc ' - set -euo pipefail - # 导出流水线复用固定源码目录时,先清掉本地改动,确保执行的是 Jenkins SCM 检出的脚本。 - git reset --hard HEAD - export_dir="${params.OUTPUT_DIRECTORY}" - if [[ -z "\${export_dir}" ]]; then - export_dir="database-exports" - fi - mkdir -p "\${export_dir}" - output_path="\${export_dir}/${env.EFFECTIVE_EXPORT_NAME}" - args=(scripts/spacetime-export-migration-json.mjs --out "\${output_path}") - if [[ -n "${params.DATABASE}" ]]; then - args+=(--database "${params.DATABASE}") - fi - if [[ -n "${params.SERVER}" ]]; then - args+=(--server "${params.SERVER}") - fi - if [[ -n "${params.SERVER_URL}" ]]; then - args+=(--server-url "${params.SERVER_URL}") - fi - if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then - args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}") - fi - if [[ -n "${params.INCLUDE_TABLES}" ]]; then - args+=(--include "${params.INCLUDE_TABLES}") - fi - if [[ -n "${params.BOOTSTRAP_SECRET}" ]]; then - args+=(--bootstrap-secret "${params.BOOTSTRAP_SECRET}") - fi - # 复用后端迁移 procedure 导出 JSON,避免 Jenkins 直接拼接表结构和 SQL。 - node "\${args[@]}" - test -s "\${output_path}" - ' - """ - - archiveArtifacts artifacts: "${params.OUTPUT_DIRECTORY ?: 'database-exports'}/${env.EFFECTIVE_EXPORT_NAME}", fingerprint: true - } - } - } - } - - post { - success { - echo "数据库导出完成: ${env.EFFECTIVE_EXPORT_NAME}" - } - } -} diff --git a/jenkins/Jenkinsfile.database-import b/jenkins/Jenkinsfile.database-import deleted file mode 100644 index 140fa23d..00000000 --- a/jenkins/Jenkinsfile.database-import +++ /dev/null @@ -1,126 +0,0 @@ -pipeline { - agent none - - options { - disableConcurrentBuilds() - timestamps() - } - - environment { - GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" - PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - } - - parameters { - string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') - string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') - string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') - string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') - string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL,填写后优先于 SERVER') - string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录,ROOT_DIR 为空时使用其 .spacetimedb') - string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir,可选,优先于 DEPLOY_DIRECTORY') - string(name: 'INPUT_FILE', defaultValue: '', description: '必填,迁移 JSON 文件路径,相对源码根目录或绝对路径') - string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') - string(name: 'CHUNK_SIZE', defaultValue: '524288', description: '迁移 JSON 分片大小,默认 512KiB,用于规避 SpacetimeDB HTTP 413') - booleanParam(name: 'DRY_RUN', defaultValue: true, description: '仅校验导入,不写入数据') - booleanParam(name: 'INCREMENTAL', defaultValue: true, description: '增量导入,跳过已存在或冲突的行') - booleanParam(name: 'REPLACE_EXISTING', defaultValue: false, description: '覆盖本次文件内涉及的表,不可与 INCREMENTAL 同时启用') - string(name: 'BOOTSTRAP_SECRET', defaultValue: '', description: '可选,授权临时导入 identity 的迁移引导密钥') - string(name: 'TOKEN', defaultValue: '', description: '可选,SpacetimeDB 客户端连接 token;留空则自动创建临时 identity') - string(name: 'NOTE', defaultValue: 'jenkins database import', description: '迁移授权备注') - } - - stages { - stage('校验参数') { - agent { - label 'built-in' - } - - steps { - script { - if (!params.INPUT_FILE?.trim()) { - error('INPUT_FILE 不能为空。') - } - if (params.INCREMENTAL && params.REPLACE_EXISTING) { - error('INCREMENTAL 不能和 REPLACE_EXISTING 同时启用。') - } - def deployDirectory = params.DEPLOY_DIRECTORY?.trim() - if (!deployDirectory) { - error('DEPLOY_DIRECTORY 不能为空。') - } - env.EFFECTIVE_ROOT_DIR = params.ROOT_DIR?.trim() ? params.ROOT_DIR.trim() : "${deployDirectory}/.spacetimedb" - } - } - } - - stage('导入数据库') { - agent { - label "${params.AGENT_LABEL}" - } - - steps { - script { - // 固定源码目录可复用 Jenkins Agent 上的脚本和依赖,未指定时回退到当前工作区。 - env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() - } - - dir("${env.WORKSPACE_ROOT}") { - checkout scm - - sh """ - bash -lc ' - set -euo pipefail - # 导入流水线复用固定源码目录时,先清掉本地改动,确保迁移脚本来自 Jenkins SCM 检出的版本。 - git reset --hard HEAD - args=(scripts/spacetime-import-migration-json.mjs --in "${params.INPUT_FILE}") - if [[ -n "${params.DATABASE}" ]]; then - args+=(--database "${params.DATABASE}") - fi - if [[ -n "${params.SERVER}" ]]; then - args+=(--server "${params.SERVER}") - fi - if [[ -n "${params.SERVER_URL}" ]]; then - args+=(--server-url "${params.SERVER_URL}") - fi - if [[ -n "${env.EFFECTIVE_ROOT_DIR}" ]]; then - args+=(--root-dir "${env.EFFECTIVE_ROOT_DIR}") - fi - if [[ -n "${params.INCLUDE_TABLES}" ]]; then - args+=(--include "${params.INCLUDE_TABLES}") - fi - if [[ -n "${params.CHUNK_SIZE}" ]]; then - args+=(--chunk-size "${params.CHUNK_SIZE}") - fi - if [[ "${params.DRY_RUN}" == "true" ]]; then - args+=(--dry-run) - fi - if [[ "${params.INCREMENTAL}" == "true" ]]; then - args+=(--incremental) - fi - if [[ "${params.REPLACE_EXISTING}" == "true" ]]; then - args+=(--replace-existing) - fi - if [[ -n "${params.BOOTSTRAP_SECRET}" ]]; then - args+=(--bootstrap-secret "${params.BOOTSTRAP_SECRET}") - fi - if [[ -n "${params.TOKEN}" ]]; then - args+=(--token "${params.TOKEN}") - fi - if [[ -n "${params.NOTE}" ]]; then - args+=(--note "${params.NOTE}") - fi - # 导入默认 dry-run,并通过迁移 procedure 写入,保持权限校验和表级统计一致。 - node "\${args[@]}" - ' - """ - } - } - } - } - - post { - success { - echo "数据库导入流水线完成,dry-run: ${params.DRY_RUN}" - } - } -} diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy deleted file mode 100644 index c06238f6..00000000 --- a/jenkins/Jenkinsfile.deploy +++ /dev/null @@ -1,162 +0,0 @@ -pipeline { - agent none - - options { - disableConcurrentBuilds() - timestamps() - } - - environment { - GENARRATIVE_TOOLS_PATH = "/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin" - PATH = "${GENARRATIVE_TOOLS_PATH}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - } - - parameters { - string(name: 'SOURCE_NODE_NAME', defaultValue: '', description: '上游构建节点名') - string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录') - string(name: 'BUILD_VERSION', defaultValue: '', description: '待部署版本号') - string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录') - string(name: 'WEB_PORT', defaultValue: '25001', description: '静态网站监听端口,默认 25001,上游构建并部署流水线会透传同名参数') - booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') - booleanParam(name: 'MIGRATE_ON_CONFLICT', defaultValue: true, description: '普通发布遇到 SpacetimeDB schema 冲突时自动导出、清库发布并导入回灌') - string(name: 'MIGRATION_DIRECTORY', defaultValue: '', description: '自动迁移 JSON 输出目录,留空则使用部署目录内 database-migrations/') - password(name: 'MIGRATION_EXPORT_TOKEN', defaultValue: '', description: '可选,旧库已授权迁移操作员 token,仅用于 schema 冲突导出') - password(name: 'MIGRATION_IMPORT_TOKEN', defaultValue: '', description: '可选,新库已授权迁移操作员 token,仅用于 schema 冲突导入') - booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', defaultValue: true, description: 'start.sh / stop.sh 是否通过 sudo -n 执行') - string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名') - } - - stages { - stage('校验触发来源') { - agent { - label 'built-in' - } - - steps { - script { - // 部署流水线允许手动启动;如存在上游触发原因,则继续执行上游作业名门禁。 - // Pipeline 的 build 步骤通常会把下游触发原因记录成 BuildUpstreamCause, - // 直接只查经典 UpstreamCause 会把真实的上游触发误判成“人工执行”。 - def pipelineUpstreamCauses = currentBuild.getBuildCauses('org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause') - def classicUpstreamCauses = currentBuild.getBuildCauses('hudson.model.Cause$UpstreamCause') - def upstreamCause = null - - if (pipelineUpstreamCauses && !pipelineUpstreamCauses.isEmpty()) { - upstreamCause = pipelineUpstreamCauses[0] - } else if (classicUpstreamCauses && !classicUpstreamCauses.isEmpty()) { - upstreamCause = classicUpstreamCauses[0] - } - - def actualUpstreamJob = upstreamCause?.upstreamProject ?: '' - def expectedUpstreamJob = params.EXPECTED_UPSTREAM_JOB?.trim() - def allowedUpstreamJob = env.GENARRATIVE_ALLOWED_UPSTREAM_JOB?.trim() - - if (!params.BUILD_VERSION?.trim()) { - error('BUILD_VERSION 不能为空。') - } - - if (!params.SOURCE_WORKSPACE_ROOT?.trim()) { - error('SOURCE_WORKSPACE_ROOT 不能为空。') - } - - if (!params.SOURCE_NODE_NAME?.trim()) { - error('SOURCE_NODE_NAME 不能为空。') - } - - def webPort = params.WEB_PORT?.trim() - if (!webPort) { - error('WEB_PORT 不能为空。') - } - - if (!(webPort ==~ /^[0-9]+$/)) { - error("WEB_PORT 必须是数字端口,当前值: ${webPort}") - } - - if (webPort.length() > 5) { - error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") - } - - def parsedWebPort = webPort.toInteger() - if (parsedWebPort < 1 || parsedWebPort > 65535) { - error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") - } - // 部署脚本只接收校验后的端口值,避免手工参数前后空格传到 Bash。 - env.EFFECTIVE_WEB_PORT = webPort - - if (upstreamCause && !actualUpstreamJob?.trim()) { - error('无法从上游触发原因中解析作业名,请检查 Jenkins Pipeline Build Step 插件版本与触发链。') - } - - if (actualUpstreamJob && expectedUpstreamJob && actualUpstreamJob != expectedUpstreamJob) { - error("上游作业校验失败,期望 ${expectedUpstreamJob},实际 ${actualUpstreamJob}") - } - - if (actualUpstreamJob && allowedUpstreamJob && actualUpstreamJob != allowedUpstreamJob) { - error("环境门禁校验失败,仅允许 ${allowedUpstreamJob} 触发,实际 ${actualUpstreamJob}") - } - - env.UPSTREAM_JOB_NAME = actualUpstreamJob ?: 'MANUAL' - } - } - } - - stage('部署指定版本') { - agent { - label "${params.SOURCE_NODE_NAME}" - } - - steps { - script { - // 部署脚本使用当前 Deploy 作业 checkout 出来的版本,避免重放旧 build 目录时继续执行旧脚本。 - env.DEPLOY_SCRIPT_PATH = "${pwd()}/scripts/jenkins-deploy-release.sh" - } - - dir("${params.SOURCE_WORKSPACE_ROOT}") { - sh """ - bash -lc ' - set -euo pipefail - # 部署流水线也先清回上游工作区的当前提交,避免复用目录中的本地改动影响部署脚本或产物选择。 - git reset --hard HEAD - test -d "build/${params.BUILD_VERSION}" - deploy_script="${env.DEPLOY_SCRIPT_PATH}" - chmod +x "\${deploy_script}" - deploy_args=( - --source-dir "build/${params.BUILD_VERSION}" - --deploy-dir "${params.DEPLOY_DIRECTORY}" - --web-port "${env.EFFECTIVE_WEB_PORT}" - ) - if [[ "${params.CLEAR_DATABASE}" == "true" ]]; then - deploy_args+=(--clear-database) - fi - if [[ "${params.MIGRATE_ON_CONFLICT}" == "true" ]]; then - deploy_args+=(--migrate-on-conflict) - else - deploy_args+=(--no-migrate-on-conflict) - fi - if [[ -n "${params.MIGRATION_DIRECTORY}" ]]; then - deploy_args+=(--migration-dir "${params.MIGRATION_DIRECTORY}") - fi - if [[ -n "${params.MIGRATION_EXPORT_TOKEN}" ]]; then - deploy_args+=(--migration-export-token "${params.MIGRATION_EXPORT_TOKEN}") - fi - if [[ -n "${params.MIGRATION_IMPORT_TOKEN}" ]]; then - deploy_args+=(--migration-import-token "${params.MIGRATION_IMPORT_TOKEN}") - fi - if [[ "${params.RUN_DEPLOY_HOOKS_WITH_SUDO}" == "true" ]]; then - deploy_args+=(--hook-with-sudo) - fi - # 只部署上游已构建好的版本目录,避免部署阶段再次构建产生漂移。 - "\${deploy_script}" "\${deploy_args[@]}" - ' - """ - } - } - } - } - - post { - success { - echo "部署完成,版本号: ${params.BUILD_VERSION},上游作业: ${env.UPSTREAM_JOB_NAME}" - } - } -} diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build new file mode 100644 index 00000000..85156be8 --- /dev/null +++ b/jenkins/Jenkinsfile.production-api-build @@ -0,0 +1,105 @@ +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + CARGO_HOME = '/var/cache/genarrative-build/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_INCREMENTAL = '0' + RUSTC_WRAPPER = 'sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_CACHE_SIZE = '30G' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 API 发布') + string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名') + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: 'PUBLISH_AFTER_BUILD=true 且目标为 release 时必须确认已有独立 release 部署 agent') + } + + stages { + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + } + } + } + + stage('Build Api') { + steps { + sh ''' + bash -lc ' + set -euo pipefail + mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ + npm run build:production-release -- --component api-server --name "${EFFECTIVE_BUILD_VERSION}" + ' + ''' + } + } + + stage('Archive') { + steps { + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + } + } + + stage('Publish') { + when { + expression { return params.PUBLISH_AFTER_BUILD } + } + steps { + build job: params.DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER), + ] + } + } + } + + post { + success { + echo "API 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy new file mode 100644 index 00000000..d5601910 --- /dev/null +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -0,0 +1,119 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') + string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') + string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') + string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录') + string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') + string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名') + string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '本机健康检查地址') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。') + } + if (!params.BUILD_VERSION?.trim()) { + error('BUILD_VERSION 不能为空。') + } + if (!params.BUILD_JOB_NAME?.trim()) { + error('BUILD_JOB_NAME 不能为空。') + } + if (!params.BUILD_NUMBER_TO_DEPLOY?.trim()) { + error('BUILD_NUMBER_TO_DEPLOY 不能为空。') + } + } + } + } + + stage('Checkout Deploy Scripts') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + } + } + + stage('Fetch Artifact') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + copyArtifacts( + projectName: params.BUILD_JOB_NAME, + selector: specific(params.BUILD_NUMBER_TO_DEPLOY), + filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + target: '.', + fingerprintArtifacts: true + ) + } + } + + stage('Deploy Api') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/deploy/production-api-deploy.sh scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh + scripts/deploy/production-api-deploy.sh \ + --source-dir "build/${BUILD_VERSION}" \ + --version "${BUILD_VERSION}" \ + --release-root "${RELEASE_ROOT}" \ + --current-link "${CURRENT_LINK}" \ + --service "${SERVICE_NAME}" \ + --health-url "${HEALTH_URL}" + ' + ''' + } + } + } + + post { + success { + echo "API 发布完成: version=${params.BUILD_VERSION}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export new file mode 100644 index 00000000..94294431 --- /dev/null +++ b/jenkins/Jenkinsfile.production-database-export @@ -0,0 +1,198 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑导出目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导出脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '导出脚本来源 commit') + string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') + string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') + string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') + string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;release 自托管默认 /stdb') + string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') + string(name: 'WORKSPACE_EXPORT_DIRECTORY', defaultValue: 'database-exports', description: 'Jenkins workspace 内的导出目录,用于归档') + string(name: 'SERVER_BACKUP_DIRECTORY', defaultValue: '/var/lib/genarrative/database-exports', description: '可选,额外保存在目标机器上的备份目录;留空则不保存服务器副本') + string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则使用 spacetime-migration-.json') + string(name: 'TOKEN_CREDENTIAL_ID', defaultValue: '', description: '可选,SpacetimeDB 客户端连接 token 的 Jenkins Secret Text 凭据 ID') + string(name: 'BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 数据库导出需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') + } + if (!params.DATABASE?.trim()) { + error('DATABASE 不能为空。') + } + if (!(params.DATABASE.trim() ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) { + error("DATABASE 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${params.DATABASE}") + } + + def spacetimeRootDir = params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb' + if (!spacetimeRootDir.startsWith('/') || spacetimeRootDir.contains('..')) { + error("SPACETIME_ROOT_DIR 必须是 Linux 绝对路径且不能包含 ..: ${spacetimeRootDir}") + } + + def serverBackupDirectory = params.SERVER_BACKUP_DIRECTORY?.trim() + if (serverBackupDirectory && (!serverBackupDirectory.startsWith('/') || serverBackupDirectory.contains('..'))) { + error("SERVER_BACKUP_DIRECTORY 必须是 Linux 绝对路径且不能包含 ..: ${serverBackupDirectory}") + } + + def exportDirectory = params.WORKSPACE_EXPORT_DIRECTORY?.trim() ? params.WORKSPACE_EXPORT_DIRECTORY.trim() : 'database-exports' + if (exportDirectory.startsWith('/') || exportDirectory.contains('..') || !(exportDirectory ==~ /^[A-Za-z0-9._\/-]+$/)) { + error("WORKSPACE_EXPORT_DIRECTORY 必须是安全的相对路径: ${exportDirectory}") + } + + def exportName = params.EXPORT_NAME?.trim() + if (!exportName) { + exportName = "spacetime-migration-${env.BUILD_NUMBER}.json" + } + if (!(exportName ==~ /^[A-Za-z0-9._-]+$/)) { + error("EXPORT_NAME 只能包含字母、数字、点、下划线和短横线: ${exportName}") + } + + env.WORKSPACE_EXPORT_DIRECTORY = exportDirectory + env.EFFECTIVE_EXPORT_NAME = exportName + env.EFFECTIVE_SPACETIME_ROOT_DIR = spacetimeRootDir + env.EFFECTIVE_SERVER_BACKUP_DIRECTORY = serverBackupDirectory ?: '' + } + } + } + + stage('Export Database') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + def credentialBindings = [] + if (params.TOKEN_CREDENTIAL_ID?.trim()) { + credentialBindings.add(string(credentialsId: params.TOKEN_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_TOKEN')) + } + if (params.BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { + credentialBindings.add(string(credentialsId: params.BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET')) + } + + def exportStep = { + sh ''' + bash -lc ' + set -euo pipefail + + chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh + + export_dir="${WORKSPACE_EXPORT_DIRECTORY}" + output_path="${export_dir}/${EFFECTIVE_EXPORT_NAME}" + mkdir -p "${export_dir}" + + maintenance_entered=0 + on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${maintenance_entered}" -eq 1 ]]; then + echo "[database-export] 导出失败,保持维护模式。" >&2 + elif [[ "${exit_code}" -ne 0 ]]; then + echo "[database-export] 导出准备失败,尚未进入维护模式。" >&2 + fi + exit "${exit_code}" + } + trap on_exit EXIT + + scripts/deploy/maintenance-on.sh "database export ${DATABASE}" + maintenance_entered=1 + + args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${DATABASE}") + if [[ -n "${SPACETIME_SERVER_URL}" ]]; then + args+=(--server-url "${SPACETIME_SERVER_URL}") + elif [[ -n "${SPACETIME_SERVER}" ]]; then + args+=(--server "${SPACETIME_SERVER}") + fi + if [[ -n "${EFFECTIVE_SPACETIME_ROOT_DIR}" ]]; then + args+=(--root-dir "${EFFECTIVE_SPACETIME_ROOT_DIR}") + fi + if [[ -n "${INCLUDE_TABLES}" ]]; then + args+=(--include "${INCLUDE_TABLES}") + fi + args+=(--note "jenkins database export ${BUILD_TAG}") + + node "${args[@]}" + test -s "${output_path}" + sha256sum "${output_path}" >"${output_path}.sha256" + + if [[ -n "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" ]]; then + mkdir -p "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" + install -m 0640 "${output_path}" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}" + install -m 0640 "${output_path}.sha256" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}.sha256" + fi + + echo "[database-export] 完成: ${output_path}, source_commit=$(cat .jenkins-source-commit)" + ' + ''' + } + + boolean archiveSucceeded = false + try { + if (credentialBindings) { + withCredentials(credentialBindings) { + exportStep() + } + } else { + exportStep() + } + archiveArtifacts artifacts: "${env.WORKSPACE_EXPORT_DIRECTORY}/${env.EFFECTIVE_EXPORT_NAME},${env.WORKSPACE_EXPORT_DIRECTORY}/${env.EFFECTIVE_EXPORT_NAME}.sha256", fingerprint: true + archiveSucceeded = true + } finally { + if (archiveSucceeded) { + // 先确认导出和归档都已完成,再退出维护模式,避免归档异常把站点留在维护页。 + sh ''' + bash -lc ' + set -euo pipefail + scripts/deploy/maintenance-off.sh + ' + ''' + } + } + } + } + } + } + + post { + success { + echo "数据库导出完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, file=${env.EFFECTIVE_EXPORT_NAME}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import new file mode 100644 index 00000000..8cb92c53 --- /dev/null +++ b/jenkins/Jenkinsfile.production-database-import @@ -0,0 +1,315 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑导入目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导入脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '导入脚本来源 commit') + string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') + string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') + string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') + string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;release 自托管默认 /stdb') + choice(name: 'INPUT_SOURCE', choices: ['pipeline_archive', 'manual_upload'], description: '导入数据源;pipeline_archive 从导出流水线归档获取,manual_upload 使用本次构建手动上传文件') + string(name: 'INPUT_FILE', defaultValue: '', description: 'pipeline_archive 模式可选;留空时使用导出流水线默认归档路径 database-exports/spacetime-migration-<导出构建号>.json') + string(name: 'EXPORT_JOB_NAME', defaultValue: 'Genarrative-Database-Export', description: 'pipeline_archive 模式使用的数据库导出流水线作业名') + string(name: 'EXPORT_BUILD_NUMBER_TO_IMPORT', defaultValue: '', description: 'pipeline_archive 模式必填,要复制 INPUT_FILE 的导出构建号') + stashedFile 'MANUAL_INPUT_FILE' + string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') + string(name: 'CHUNK_SIZE', defaultValue: '524288', description: '迁移 JSON 分片大小,默认 512KiB,用于规避 HTTP 413') + booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只校验导入,不写入数据') + booleanParam(name: 'INCREMENTAL', defaultValue: true, description: '增量导入,跳过已存在或冲突的行') + booleanParam(name: 'REPLACE_EXISTING', defaultValue: false, description: '覆盖本次文件内涉及的表,不可与 INCREMENTAL 同时启用') + booleanParam(name: 'CONFIRM_IMPORT', defaultValue: false, description: 'DRY_RUN=false 时必须勾选') + string(name: 'CONFIRM_DATABASE', defaultValue: '', description: 'DRY_RUN=false 时必须填写与 DATABASE 完全一致') + string(name: 'CONFIRM_INPUT_FILE', defaultValue: '', description: 'DRY_RUN=false 时必须确认输入文件;pipeline_archive 填实际归档输入路径,manual_upload 填上传原始文件名') + booleanParam(name: 'CONFIRM_REPLACE_EXISTING', defaultValue: false, description: 'REPLACE_EXISTING=true 且 DRY_RUN=false 时必须勾选') + string(name: 'PRE_IMPORT_BACKUP_DIRECTORY', defaultValue: 'database-pre-import-backups', description: 'Jenkins workspace 内的导入前备份目录,用于归档') + string(name: 'SERVER_BACKUP_DIRECTORY', defaultValue: '/var/lib/genarrative/database-backups', description: '可选,额外保存在目标机器上的导入前备份目录;留空则不保存服务器副本') + booleanParam(name: 'RUN_SMOKE_TEST', defaultValue: true, description: '导入成功后是否执行服务健康检查') + string(name: 'SMOKE_HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '目标机器本机健康检查地址') + string(name: 'TOKEN_CREDENTIAL_ID', defaultValue: '', description: '可选,SpacetimeDB 客户端连接 token 的 Jenkins Secret Text 凭据 ID') + string(name: 'BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 数据库导入需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') + } + if (!params.DATABASE?.trim()) { + error('DATABASE 不能为空。') + } + if (!(params.DATABASE.trim() ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) { + error("DATABASE 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${params.DATABASE}") + } + + def inputSource = params.INPUT_SOURCE?.trim() + if (!(inputSource in ['pipeline_archive', 'manual_upload'])) { + error("INPUT_SOURCE 只能是 pipeline_archive 或 manual_upload,当前值: ${params.INPUT_SOURCE}") + } + def manualInputFilename = env.MANUAL_INPUT_FILE_FILENAME?.trim() + if (inputSource == 'pipeline_archive') { + if (!params.EXPORT_JOB_NAME?.trim()) { + error('INPUT_SOURCE=pipeline_archive 时 EXPORT_JOB_NAME 不能为空。') + } + if (!params.EXPORT_BUILD_NUMBER_TO_IMPORT?.trim()) { + error('INPUT_SOURCE=pipeline_archive 时 EXPORT_BUILD_NUMBER_TO_IMPORT 不能为空。') + } + if (!(params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim() ==~ /^[1-9][0-9]*$/)) { + error("INPUT_SOURCE=pipeline_archive 时 EXPORT_BUILD_NUMBER_TO_IMPORT 必须是导出流水线构建号: ${params.EXPORT_BUILD_NUMBER_TO_IMPORT}") + } + def pipelineInputFile = params.INPUT_FILE?.trim() + if (!pipelineInputFile) { + pipelineInputFile = "database-exports/spacetime-migration-${params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim()}.json" + } + if (pipelineInputFile.startsWith('/')) { + error('INPUT_SOURCE=pipeline_archive 时 INPUT_FILE 必须是 Jenkins 归档内的 workspace 相对路径。') + } + if (pipelineInputFile.contains('..') || !(pipelineInputFile ==~ /^[A-Za-z0-9._\/-]+$/)) { + error("INPUT_SOURCE=pipeline_archive 时 INPUT_FILE 必须是安全的归档相对路径: ${pipelineInputFile}") + } + if (manualInputFilename) { + error('INPUT_SOURCE=pipeline_archive 时不能同时上传 MANUAL_INPUT_FILE。') + } + env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE = pipelineInputFile + } else { + if (!manualInputFilename) { + error('INPUT_SOURCE=manual_upload 时必须上传 MANUAL_INPUT_FILE。') + } + if (params.EXPORT_BUILD_NUMBER_TO_IMPORT?.trim()) { + error('INPUT_SOURCE=manual_upload 时不能填写 EXPORT_BUILD_NUMBER_TO_IMPORT。') + } + if (params.INPUT_FILE?.trim()) { + error('INPUT_SOURCE=manual_upload 时不能填写 INPUT_FILE;请使用 MANUAL_INPUT_FILE 上传数据源。') + } + } + if (params.INCREMENTAL && params.REPLACE_EXISTING) { + error('INCREMENTAL 不能和 REPLACE_EXISTING 同时启用。') + } + if (!params.DRY_RUN) { + if (!params.CONFIRM_IMPORT) { + error('DRY_RUN=false 时必须勾选 CONFIRM_IMPORT。') + } + if (params.CONFIRM_DATABASE?.trim() != params.DATABASE.trim()) { + error('DRY_RUN=false 时 CONFIRM_DATABASE 必须与 DATABASE 完全一致。') + } + if (inputSource == 'pipeline_archive' && params.CONFIRM_INPUT_FILE?.trim() != env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE) { + error('DRY_RUN=false 时 CONFIRM_INPUT_FILE 必须与实际归档输入路径完全一致。') + } + if (inputSource == 'manual_upload' && !params.CONFIRM_INPUT_FILE?.trim()) { + error('DRY_RUN=false 且 INPUT_SOURCE=manual_upload 时 CONFIRM_INPUT_FILE 必须填写上传文件原始文件名。') + } + if (inputSource == 'manual_upload' && params.CONFIRM_INPUT_FILE?.trim() != manualInputFilename) { + error('DRY_RUN=false 且 INPUT_SOURCE=manual_upload 时 CONFIRM_INPUT_FILE 必须与上传文件原始文件名完全一致。') + } + if (params.REPLACE_EXISTING && !params.CONFIRM_REPLACE_EXISTING) { + error('REPLACE_EXISTING=true 且 DRY_RUN=false 时必须勾选 CONFIRM_REPLACE_EXISTING。') + } + } + + def backupDirectory = params.PRE_IMPORT_BACKUP_DIRECTORY?.trim() ? params.PRE_IMPORT_BACKUP_DIRECTORY.trim() : 'database-pre-import-backups' + if (backupDirectory.startsWith('/') || backupDirectory.contains('..') || !(backupDirectory ==~ /^[A-Za-z0-9._\/-]+$/)) { + error("PRE_IMPORT_BACKUP_DIRECTORY 必须是安全的相对路径: ${backupDirectory}") + } + + env.PRE_IMPORT_BACKUP_DIRECTORY = backupDirectory + env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME = "pre-import-${env.BUILD_NUMBER}.json" + } + } + } + + stage('Import Database') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + if (params.INPUT_SOURCE == 'pipeline_archive') { + echo "[database-import] 使用归档数据源: job=${params.EXPORT_JOB_NAME}, build=${params.EXPORT_BUILD_NUMBER_TO_IMPORT}, file=${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE}" + copyArtifacts( + projectName: params.EXPORT_JOB_NAME, + selector: specific(params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim()), + filter: "${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE},${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE}.sha256", + target: '.', + fingerprintArtifacts: true + ) + env.EFFECTIVE_INPUT_FILE = env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE + } else { + echo "[database-import] 使用手动上传数据源: original_filename=${env.MANUAL_INPUT_FILE_FILENAME}" + sh 'bash -lc "rm -rf manual-import-upload && mkdir -p manual-import-upload"' + dir('manual-import-upload') { + unstash 'MANUAL_INPUT_FILE' + } + env.EFFECTIVE_INPUT_FILE = 'manual-import-upload/MANUAL_INPUT_FILE' + if (!params.DRY_RUN) { + sh ''' + bash -lc ' + set -euo pipefail + manual_filename="${MANUAL_INPUT_FILE_FILENAME:-}" + if [[ -z "${manual_filename}" ]]; then + echo "[database-import] 无法读取 MANUAL_INPUT_FILE_FILENAME,不能确认手动上传文件名。" >&2 + exit 1 + fi + if [[ "${CONFIRM_INPUT_FILE}" != "${manual_filename}" ]]; then + echo "[database-import] CONFIRM_INPUT_FILE 必须与手动上传文件原始文件名一致: ${manual_filename}" >&2 + exit 1 + fi + ' + ''' + } + } + + def credentialBindings = [] + if (params.TOKEN_CREDENTIAL_ID?.trim()) { + credentialBindings.add(string(credentialsId: params.TOKEN_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_TOKEN')) + } + if (params.BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { + credentialBindings.add(string(credentialsId: params.BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET')) + } + + def importStep = { + sh ''' + bash -lc ' + set -euo pipefail + + chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh + + input_path="${EFFECTIVE_INPUT_FILE}" + if [[ "${input_path}" != /* ]]; then + input_path="${WORKSPACE}/${input_path}" + fi + if [[ ! -s "${input_path}" ]]; then + echo "[database-import] INPUT_FILE 不存在或为空: ${input_path}" >&2 + exit 1 + fi + + backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY}" + backup_path="${backup_dir}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}" + mkdir -p "${backup_dir}" + + completed=0 + on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${completed}" -ne 1 ]]; then + echo "[database-import] 导入失败,保持维护模式。导入前备份如已生成,会保留在 ${backup_path}。" >&2 + fi + exit "${exit_code}" + } + trap on_exit EXIT + + scripts/deploy/maintenance-on.sh "database import ${DATABASE}" + + backup_args=(scripts/spacetime-export-migration-json.mjs --out "${backup_path}" --database "${DATABASE}") + import_args=(scripts/spacetime-import-migration-json.mjs --in "${input_path}" --database "${DATABASE}") + for args_name in backup_args import_args; do + declare -n current_args="${args_name}" + # server-url 明确指向目标实例时,不再同时透传默认 alias,避免 CLI 授权与 HTTP 导入落到不同目标。 + if [[ -n "${SPACETIME_SERVER_URL}" ]]; then + current_args+=(--server-url "${SPACETIME_SERVER_URL}") + elif [[ -n "${SPACETIME_SERVER}" ]]; then + current_args+=(--server "${SPACETIME_SERVER}") + fi + if [[ -n "${SPACETIME_ROOT_DIR}" ]]; then + current_args+=(--root-dir "${SPACETIME_ROOT_DIR}") + fi + done + + backup_args+=(--note "jenkins pre-import backup ${BUILD_TAG}") + node "${backup_args[@]}" + test -s "${backup_path}" + sha256sum "${backup_path}" >"${backup_path}.sha256" + + if [[ -n "${SERVER_BACKUP_DIRECTORY}" ]]; then + mkdir -p "${SERVER_BACKUP_DIRECTORY}" + install -m 0640 "${backup_path}" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}" + install -m 0640 "${backup_path}.sha256" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256" + fi + + if [[ -n "${INCLUDE_TABLES}" ]]; then + import_args+=(--include "${INCLUDE_TABLES}") + fi + if [[ -n "${CHUNK_SIZE}" ]]; then + import_args+=(--chunk-size "${CHUNK_SIZE}") + fi + if [[ "${DRY_RUN}" == "true" ]]; then + import_args+=(--dry-run) + fi + if [[ "${INCREMENTAL}" == "true" ]]; then + import_args+=(--incremental) + fi + if [[ "${REPLACE_EXISTING}" == "true" ]]; then + import_args+=(--replace-existing) + fi + import_args+=(--note "jenkins database import ${BUILD_TAG}") + + node "${import_args[@]}" + + # 导入成功后只做本机健康检查;业务级数据核验仍以迁移脚本的表级统计为准。 + if [[ "${RUN_SMOKE_TEST}" == "true" && -n "${SMOKE_HEALTH_URL}" ]]; then + curl -fsS --max-time 10 "${SMOKE_HEALTH_URL}" >/dev/null + fi + + scripts/deploy/maintenance-off.sh + completed=1 + echo "[database-import] 完成: dry_run=${DRY_RUN}, database=${DATABASE}, source_commit=$(cat .jenkins-source-commit)" + ' + ''' + } + + if (credentialBindings) { + withCredentials(credentialBindings) { + importStep() + } + } else { + importStep() + } + } + } + post { + always { + archiveArtifacts artifacts: "${env.PRE_IMPORT_BACKUP_DIRECTORY}/${env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME},${env.PRE_IMPORT_BACKUP_DIRECTORY}/${env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256", allowEmptyArchive: true, fingerprint: true + } + } + } + } + + post { + success { + echo "数据库导入流水线完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, dryRun=${params.DRY_RUN}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy new file mode 100644 index 00000000..47d59db6 --- /dev/null +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -0,0 +1,176 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + triggers { + cron('0 4 * * *') + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') + string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') + string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名') + string(name: 'WEB_DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') + string(name: 'API_DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名') + string(name: 'STDB_PUBLISH_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb 发布流水线作业名') + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') + } + + stages { + stage('Resolve Source') { + agent { + label 'linux && genarrative-build' + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。') + } + } + } + } + + stage('Build Components') { + parallel { + stage('Web') { + steps { + script { + def webRun = build job: params.WEB_BUILD_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + ] + env.WEB_BUILD_NUMBER = webRun.number.toString() + } + } + } + stage('Api') { + steps { + script { + def apiRun = build job: params.API_BUILD_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + ] + env.API_BUILD_NUMBER = apiRun.number.toString() + } + } + } + stage('Stdb') { + steps { + script { + def stdbRun = build job: params.STDB_BUILD_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DATABASE', value: params.DATABASE), + ] + env.STDB_BUILD_NUMBER = stdbRun.number.toString() + } + } + } + } + } + + stage('Deploy Stdb') { + steps { + build job: params.STDB_PUBLISH_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DATABASE', value: params.DATABASE), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: params.STDB_BUILD_JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.STDB_BUILD_NUMBER), + ] + } + } + + stage('Deploy Api') { + steps { + build job: params.API_DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: params.API_BUILD_JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.API_BUILD_NUMBER), + ] + } + } + + stage('Deploy Web') { + steps { + build job: params.WEB_DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: params.WEB_BUILD_JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.WEB_BUILD_NUMBER), + ] + } + } + } + + post { + success { + echo "Full Build-And-Deploy 完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision new file mode 100644 index 00000000..a3d11ffe --- /dev/null +++ b/jenkins/Jenkinsfile.production-server-provision @@ -0,0 +1,241 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent') + booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run') + booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit') + string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: 'Nginx server_name 与证书域名') + string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径') + string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') + string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') + string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') + string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接') + string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') + string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') + booleanParam(name: 'INSTALL_NGINX_CONFIG', defaultValue: true, description: '安装 Nginx 配置并执行 nginx -t') + booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') + } + if (!params.DRY_RUN && !params.CONFIRM_PROVISION) { + error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。') + } + if (!params.SERVER_NAME?.trim()) { + error('SERVER_NAME 不能为空。') + } + if (!params.SPACETIME_BIN_SOURCE?.trim()) { + error('SPACETIME_BIN_SOURCE 不能为空。') + } + } + } + } + + stage('Checkout Provision Files') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + } + } + + stage('Provision Server') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + sh ''' + bash -lc ' + set -euo pipefail + + require_path() { + local path="$1" + if [[ ! -e "${path}" ]]; then + echo "[server-provision] 缺少必要文件: ${path}" >&2 + exit 1 + fi + } + + run_cmd() { + echo "+ $*" + if [[ "${DRY_RUN}" != "true" ]]; then + "$@" + fi + } + + install_file() { + local source="$1" + local target="$2" + local mode="$3" + echo "+ install -m ${mode} ${source} ${target}" + if [[ "${DRY_RUN}" != "true" ]]; then + install -m "${mode}" "${source}" "${target}" + fi + } + + render_nginx_config() { + sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf + } + + render_api_env_example() { + sed \ + -e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \ + -e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3000|" \ + deploy/env/api-server.env.example + } + + escape_sed_replacement() { + printf "%s" "$1" | sed "s/[&|]/\\\\&/g" + } + + render_spacetimedb_service() { + local root_escaped + root_escaped="$(escape_sed_replacement "${SPACETIME_ROOT}")" + sed \ + -e "s|/stdb|${root_escaped}|g" \ + deploy/systemd/spacetimedb.service + } + + render_api_service() { + local current_escaped env_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \ + deploy/systemd/genarrative-api.service + } + + require_path deploy/systemd/spacetimedb.service + require_path deploy/systemd/genarrative-api.service + require_path deploy/nginx/genarrative.conf + require_path deploy/nginx/snippets/genarrative-maintenance.conf + require_path deploy/env/api-server.env.example + require_path scripts/deploy/maintenance-on.sh + require_path scripts/deploy/maintenance-off.sh + require_path scripts/deploy/maintenance-status.sh + + echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, source_commit=$(cat .jenkins-source-commit)" + + run_cmd id + run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth + + if ! id spacetimedb >/dev/null 2>&1; then + run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb + else + echo "[server-provision] 用户已存在: spacetimedb" + fi + + if ! id genarrative >/dev/null 2>&1; then + run_cmd useradd --system --home-dir /opt/genarrative --shell /usr/sbin/nologin genarrative + else + echo "[server-provision] 用户已存在: genarrative" + fi + + run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}" + run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative + + if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then + echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2 + exit 1 + fi + echo "+ install -m 0755 ${SPACETIME_BIN_SOURCE} ${SPACETIME_ROOT}/spacetime" + if [[ "${DRY_RUN}" != "true" ]]; then + install -m 0755 "${SPACETIME_BIN_SOURCE}" "${SPACETIME_ROOT}/spacetime" + chown spacetimedb:spacetimedb "${SPACETIME_ROOT}/spacetime" + fi + + spacetimedb_service="$(mktemp)" + api_service="$(mktemp)" + render_spacetimedb_service >"${spacetimedb_service}" + render_api_service >"${api_service}" + install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644 + install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644 + rm -f "${spacetimedb_service}" "${api_service}" + + if [[ ! -f "${API_ENV_FILE}" ]]; then + echo "+ create ${API_ENV_FILE} from example" + if [[ "${DRY_RUN}" != "true" ]]; then + render_api_env_example >"${API_ENV_FILE}" + chmod 0600 "${API_ENV_FILE}" + chown root:root "${API_ENV_FILE}" + fi + else + echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" + fi + + if [[ "${INSTALL_NGINX_CONFIG}" == "true" ]]; then + run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d + echo "+ render deploy/nginx/genarrative.conf -> /etc/nginx/conf.d/genarrative.conf" + if [[ "${DRY_RUN}" != "true" ]]; then + render_nginx_config >/etc/nginx/conf.d/genarrative.conf + chmod 0644 /etc/nginx/conf.d/genarrative.conf + fi + install_file deploy/nginx/snippets/genarrative-maintenance.conf /etc/nginx/snippets/genarrative-maintenance.conf 0644 + run_cmd nginx -t + fi + + run_cmd systemctl daemon-reload + if [[ "${ENABLE_SERVICES}" == "true" ]]; then + run_cmd systemctl enable spacetimedb.service genarrative-api.service + run_cmd systemctl restart spacetimedb.service + if [[ -x "${CURRENT_LINK}/api-server" ]]; then + run_cmd systemctl restart genarrative-api.service + else + echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。" + fi + fi + + echo "[server-provision] 完成。若是首次初始化,请补齐 ${API_ENV_FILE} 的真实密钥后再启动 api-server。" + ' + ''' + } + } + } + + post { + success { + echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build new file mode 100644 index 00000000..dea4ba7b --- /dev/null +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -0,0 +1,107 @@ +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + CARGO_HOME = '/var/cache/genarrative-build/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_INCREMENTAL = '0' + RUSTC_WRAPPER = 'sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_CACHE_SIZE = '30G' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布') + string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名') + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: 'PUBLISH_AFTER_BUILD=true 且目标为 release 时必须确认已有独立 release 部署 agent') + string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') + } + + stages { + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + } + } + } + + stage('Build Stdb Module') { + steps { + sh ''' + bash -lc ' + set -euo pipefail + mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ + npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}" + ' + ''' + } + } + + stage('Archive') { + steps { + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + } + } + + stage('Publish') { + when { + expression { return params.PUBLISH_AFTER_BUILD } + } + steps { + build job: params.DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER), + string(name: 'DATABASE', value: params.DATABASE), + ] + } + } + } + + post { + success { + echo "Stdb module 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish new file mode 100644 index 00000000..2eaed5d0 --- /dev/null +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -0,0 +1,122 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') + string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名') + string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') + string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') + string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') + booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。') + } + if (!params.BUILD_VERSION?.trim()) { + error('BUILD_VERSION 不能为空。') + } + if (!params.BUILD_JOB_NAME?.trim()) { + error('BUILD_JOB_NAME 不能为空。') + } + if (!params.BUILD_NUMBER_TO_DEPLOY?.trim()) { + error('BUILD_NUMBER_TO_DEPLOY 不能为空。') + } + if (!params.DATABASE?.trim()) { + error('DATABASE 不能为空。') + } + } + } + } + + stage('Checkout Publish Scripts') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + } + } + + stage('Fetch Artifact') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + copyArtifacts( + projectName: params.BUILD_JOB_NAME, + selector: specific(params.BUILD_NUMBER_TO_DEPLOY), + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + target: '.', + fingerprintArtifacts: true + ) + } + } + + stage('Publish Stdb Module') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + script { + def clearArg = params.CLEAR_DATABASE ? '--clear-database' : '' + sh """ + bash -lc ' + set -euo pipefail + chmod +x scripts/deploy/production-stdb-publish.sh scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh + scripts/deploy/production-stdb-publish.sh \\ + --source-dir "build/${params.BUILD_VERSION}" \\ + --database "${params.DATABASE}" \\ + --server "${params.SPACETIME_SERVER}" \\ + ${clearArg} + ' + """ + } + } + } + } + + post { + success { + echo "Stdb module 发布完成: version=${params.BUILD_VERSION}, database=${params.DATABASE}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build new file mode 100644 index 00000000..c19f5e49 --- /dev/null +++ b/jenkins/Jenkinsfile.production-web-build @@ -0,0 +1,104 @@ +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') + booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') + string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: 'PUBLISH_AFTER_BUILD=true 且目标为 release 时必须确认已有独立 release 部署 agent') + } + + stages { + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + } + } + } + + stage('Build Web') { + steps { + script { + if (params.RUN_NPM_CI) { + sh 'bash -lc "npm ci"' + } + } + sh ''' + bash -lc ' + set -euo pipefail + SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ + npm run build:production-release -- --component web --name "${EFFECTIVE_BUILD_VERSION}" + ' + ''' + } + } + + stage('Archive') { + steps { + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + } + } + + stage('Publish') { + when { + expression { return params.PUBLISH_AFTER_BUILD } + } + steps { + build job: params.DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), + string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), + string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER), + ] + } + } + } + + post { + success { + echo "Web 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" + } + } +} diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy new file mode 100644 index 00000000..b61fc021 --- /dev/null +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -0,0 +1,117 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) + } + + environment { + GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + } + + parameters { + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') + string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') + string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') + string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录') + string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') + string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接') + } + + stages { + stage('Prepare') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。') + } + if (!params.BUILD_VERSION?.trim()) { + error('BUILD_VERSION 不能为空。') + } + if (!params.BUILD_JOB_NAME?.trim()) { + error('BUILD_JOB_NAME 不能为空。') + } + if (!params.BUILD_NUMBER_TO_DEPLOY?.trim()) { + error('BUILD_NUMBER_TO_DEPLOY 不能为空。') + } + } + } + } + + stage('Checkout Deploy Scripts') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], + ]) + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + ' + ''' + } + } + + stage('Fetch Artifact') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + copyArtifacts( + projectName: params.BUILD_JOB_NAME, + selector: specific(params.BUILD_NUMBER_TO_DEPLOY), + filter: "build/${params.BUILD_VERSION}/web.tar.gz,build/${params.BUILD_VERSION}/web.tar.gz.sha256,build/${params.BUILD_VERSION}/release-manifest.json", + target: '.', + fingerprintArtifacts: true + ) + } + } + + stage('Deploy Web') { + agent { + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" + } + steps { + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/deploy/production-web-deploy.sh + scripts/deploy/production-web-deploy.sh \ + --source-dir "build/${BUILD_VERSION}" \ + --version "${BUILD_VERSION}" \ + --release-root "${RELEASE_ROOT}" \ + --current-link "${CURRENT_LINK}" \ + --web-link "${WEB_LINK}" + ' + ''' + } + } + } + + post { + success { + echo "Web 发布完成: version=${params.BUILD_VERSION}" + } + } +} diff --git a/package.json b/package.json index 6cbe731e..1934d095 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "api-server:maincloud": "node scripts/api-server-maincloud.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", + "build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh new file mode 100644 index 00000000..d0b41e85 --- /dev/null +++ b/scripts/build-production-release.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + npm run build:production-release -- --name + +说明: + 1. 生成生产发布包 build//。 + 2. 发布包只包含 systemd + Nginx 生产部署需要的文件,不再生成旧 start.sh / stop.sh / web-server.mjs。 + 3. 默认会构建主站、后台、api-server 与 SpacetimeDB wasm;调试时可用 skip 参数复用已存在产物。 + +参数: + --name 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS + --component 构建组件: all, web, api-server, spacetime-module;默认 all + --skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物 + --skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制 + --skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm +EOF +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "[production-release] 缺少命令: ${command_name}" >&2 + exit 1 + fi +} + +copy_required_file() { + local source_path="$1" + local target_path="$2" + local label="$3" + + if [[ ! -f "${source_path}" ]]; then + echo "[production-release] 缺少 ${label}: ${source_path}" >&2 + exit 1 + fi + + cp "${source_path}" "${target_path}" +} + +copy_required_dir() { + local source_path="$1" + local target_path="$2" + local label="$3" + + if [[ ! -d "${source_path}" ]]; then + echo "[production-release] 缺少 ${label}: ${source_path}" >&2 + exit 1 + fi + + mkdir -p "$(dirname "${target_path}")" + rm -rf "${target_path}" + cp -R "${source_path}" "${target_path}" +} + +write_sha256_file() { + local file_path="$1" + local checksum_path="${file_path}.sha256" + + if [[ ! -f "${file_path}" ]]; then + echo "[production-release] 无法生成 checksum,文件不存在: ${file_path}" >&2 + exit 1 + fi + + ( + cd "$(dirname "${file_path}")" + sha256sum "$(basename "${file_path}")" + ) >"${checksum_path}" +} + +write_release_manifest() { + RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \ + RELEASE_VERSION="${BUILD_NAME}" \ + RELEASE_SOURCE_BRANCH="${RELEASE_SOURCE_BRANCH}" \ + RELEASE_SOURCE_COMMIT="${RELEASE_SOURCE_COMMIT}" \ + RELEASE_BUILT_AT="${RELEASE_BUILT_AT}" \ + RELEASE_COMPONENT="${COMPONENT}" \ + RELEASE_INCLUDE_WEB="${BUILD_WEB}" \ + RELEASE_INCLUDE_API="${BUILD_API}" \ + RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \ + node <<'NODE' +const fs = require('fs'); + +const artifacts = []; +if (process.env.RELEASE_INCLUDE_WEB === '1') { + artifacts.push({ + component: 'web', + path: 'web.tar.gz', + checksum_path: 'web.tar.gz.sha256', + }); +} +if (process.env.RELEASE_INCLUDE_API === '1') { + artifacts.push({ + component: 'api-server', + path: 'api-server', + checksum_path: 'api-server.sha256', + }); +} +if (process.env.RELEASE_INCLUDE_SPACETIME === '1') { + artifacts.push({ + component: 'spacetime-module', + path: 'spacetime_module.wasm', + checksum_path: 'spacetime_module.wasm.sha256', + }); +} + +const manifest = { + version: process.env.RELEASE_VERSION, + source_branch: process.env.RELEASE_SOURCE_BRANCH, + source_commit: process.env.RELEASE_SOURCE_COMMIT, + built_at: process.env.RELEASE_BUILT_AT, + component_type: process.env.RELEASE_COMPONENT, + artifacts, +}; + +fs.writeFileSync(process.env.RELEASE_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +NODE +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +SERVER_RS_DIR="${REPO_ROOT}/server-rs" +BUILD_ROOT="${REPO_ROOT}/build" +BUILD_NAME="$(date +%Y%m%d-%H%M%S)" +COMPONENT="all" +SKIP_WEB_BUILD=0 +SKIP_API_BUILD=0 +SKIP_SPACETIME_BUILD=0 +BUILD_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --name) + BUILD_NAME="${2:?缺少 --name 的值}" + shift 2 + ;; + --component) + COMPONENT="${2:?缺少 --component 的值}" + shift 2 + ;; + --skip-web-build) + SKIP_WEB_BUILD=1 + shift + ;; + --skip-api-build) + SKIP_API_BUILD=1 + shift + ;; + --skip-spacetime-build) + SKIP_SPACETIME_BUILD=1 + shift + ;; + *) + echo "[production-release] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[production-release] --name 只能包含数字、字母、点、下划线和短横线。" >&2 + exit 1 +fi + +BUILD_WEB=0 +BUILD_API=0 +BUILD_SPACETIME=0 +case "${COMPONENT}" in + all) + BUILD_WEB=1 + BUILD_API=1 + BUILD_SPACETIME=1 + ;; + web) + BUILD_WEB=1 + ;; + api|api-server) + COMPONENT="api-server" + BUILD_API=1 + ;; + stdb|spacetime|spacetime-module) + COMPONENT="spacetime-module" + BUILD_SPACETIME=1 + ;; + *) + echo "[production-release] --component 只能是 all, web, api-server 或 spacetime-module,当前值: ${COMPONENT}" >&2 + exit 1 + ;; +esac + +TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" +WEB_DIR="${TARGET_DIR}/web" +ADMIN_WEB_DIR="${WEB_DIR}/admin" +CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${SERVER_RS_DIR}/target}" +API_BINARY_SOURCE="${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/api-server" +WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module.wasm" +RELEASE_SOURCE_BRANCH="${SOURCE_BRANCH:-${GIT_BRANCH:-}}" +RELEASE_SOURCE_BRANCH="${RELEASE_SOURCE_BRANCH#origin/}" +RELEASE_SOURCE_COMMIT="${SOURCE_COMMIT:-${GIT_COMMIT:-}}" +RELEASE_BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if [[ -z "${RELEASE_SOURCE_BRANCH}" ]]; then + RELEASE_SOURCE_BRANCH="$(git -C "${REPO_ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "${RELEASE_SOURCE_BRANCH}" || "${RELEASE_SOURCE_BRANCH}" == "HEAD" ]]; then + RELEASE_SOURCE_BRANCH="unknown" + fi +fi + +if [[ -z "${RELEASE_SOURCE_COMMIT}" ]]; then + RELEASE_SOURCE_COMMIT="$(git -C "${REPO_ROOT}" rev-parse HEAD 2>/dev/null || true)" + if [[ -z "${RELEASE_SOURCE_COMMIT}" ]]; then + RELEASE_SOURCE_COMMIT="unknown" + fi +fi + +cleanup_partial_build() { + if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then + echo "[production-release] 清理未完成发布包: ${TARGET_DIR}" >&2 + rm -rf "${TARGET_DIR}" + fi +} + +trap cleanup_partial_build EXIT + +if [[ -e "${TARGET_DIR}" ]]; then + echo "[production-release] 目标目录已存在: ${TARGET_DIR}" >&2 + exit 1 +fi + +require_command node +require_command node +require_command sha256sum + +if [[ "${BUILD_API}" -eq 1 && "${SKIP_API_BUILD}" -ne 1 ]]; then + require_command cargo +fi + +if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then + require_command cargo +fi + +if [[ "${BUILD_WEB}" -eq 1 && "${SKIP_WEB_BUILD}" -ne 1 ]]; then + require_command node + require_command npm +fi + +if [[ "${BUILD_WEB}" -eq 1 ]]; then + require_command tar +fi + +mkdir -p "${TARGET_DIR}" + +echo "[production-release] 发布包目录: ${TARGET_DIR}" +echo "[production-release] 构建组件: ${COMPONENT}" + +if [[ "${BUILD_WEB}" -eq 1 ]]; then + mkdir -p "${WEB_DIR}" + if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then + echo "[production-release] 构建主站静态资源 -> ${WEB_DIR}" + ( + cd "${REPO_ROOT}" + node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir + ) + + echo "[production-release] 构建后台静态资源 -> ${ADMIN_WEB_DIR}" + ( + cd "${REPO_ROOT}" + MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir + ) + else + copy_required_dir "${REPO_ROOT}/dist" "${WEB_DIR}" "主站 dist" + copy_required_dir "${REPO_ROOT}/apps/admin-web/dist" "${ADMIN_WEB_DIR}" "后台 dist" + fi + + if [[ ! -f "${WEB_DIR}/maintenance.html" ]]; then + cat >"${WEB_DIR}/maintenance.html" <<'MAINTENANCE_HTML' + + + + + + 服务维护中 + + + +
+

服务维护中

+

我们正在更新系统,稍后请重新访问。

+
+ + +MAINTENANCE_HTML + fi + + echo "[production-release] 打包 Web 静态资源 -> ${TARGET_DIR}/web.tar.gz" + tar -czf "${TARGET_DIR}/web.tar.gz" -C "${TARGET_DIR}" web + write_sha256_file "${TARGET_DIR}/web.tar.gz" +fi + +if [[ "${BUILD_API}" -eq 1 && "${SKIP_API_BUILD}" -ne 1 ]]; then + echo "[production-release] 构建 api-server -> x86_64-unknown-linux-gnu" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p api-server \ + --release \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +if [[ "${BUILD_API}" -eq 1 ]]; then + copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary" + chmod +x "${TARGET_DIR}/api-server" + write_sha256_file "${TARGET_DIR}/api-server" +fi + +if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then + echo "[production-release] 构建 spacetime-module -> wasm32-unknown-unknown" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p spacetime-module \ + --release \ + --target wasm32-unknown-unknown \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then + copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm" + write_sha256_file "${TARGET_DIR}/spacetime_module.wasm" +fi + +mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy" +cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh" +cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh" +cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh" +chmod +x \ + "${TARGET_DIR}/scripts/maintenance-on.sh" \ + "${TARGET_DIR}/scripts/maintenance-off.sh" \ + "${TARGET_DIR}/scripts/maintenance-status.sh" + +copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本" + +copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置" +copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置" +copy_required_dir "${REPO_ROOT}/deploy/env" "${TARGET_DIR}/deploy/env" "生产环境示例" + +cat >"${TARGET_DIR}/README.md" <"${MAINTENANCE_FILE}" + +chmod 0644 "${MAINTENANCE_FILE}" +echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}" diff --git a/scripts/deploy/maintenance-status.sh b/scripts/deploy/maintenance-status.sh new file mode 100644 index 00000000..bbb4661e --- /dev/null +++ b/scripts/deploy/maintenance-status.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" + +if [[ -f "${MAINTENANCE_FILE}" ]]; then + echo "[maintenance] enabled" + cat "${MAINTENANCE_FILE}" +else + echo "[maintenance] disabled" +fi diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh new file mode 100644 index 00000000..1ee32a16 --- /dev/null +++ b/scripts/deploy/production-api-deploy.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/healthz] + +说明: + 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。 + 失败时保留维护模式。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-api-deploy] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="" +VERSION="" +RELEASE_ROOT="/opt/genarrative/releases" +CURRENT_LINK="/opt/genarrative/current" +SERVICE_NAME="genarrative-api.service" +HEALTH_URL="http://127.0.0.1:8082/healthz" +DEPLOY_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --version) + VERSION="${2:?缺少 --version 的值}" + shift 2 + ;; + --release-root) + RELEASE_ROOT="${2:?缺少 --release-root 的值}" + shift 2 + ;; + --current-link) + CURRENT_LINK="${2:?缺少 --current-link 的值}" + shift 2 + ;; + --service) + SERVICE_NAME="${2:?缺少 --service 的值}" + shift 2 + ;; + --health-url) + HEALTH_URL="${2:?缺少 --health-url 的值}" + shift 2 + ;; + *) + echo "[production-api-deploy] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-api-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" +VERSION="${VERSION:-$(basename "${SOURCE_DIR}")}" + +if [[ ! "${VERSION}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[production-api-deploy] --version 只能包含数字、字母、点、下划线和短横线: ${VERSION}" >&2 + exit 1 +fi + +if [[ ! -f "${SOURCE_DIR}/api-server" || ! -f "${SOURCE_DIR}/api-server.sha256" ]]; then + echo "[production-api-deploy] 缺少 api-server 或 api-server.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then + echo "[production-api-deploy] 部署失败,保持维护模式。" >&2 + fi + exit "${exit_code}" +} + +trap on_exit EXIT + +"${SCRIPT_DIR}/maintenance-on.sh" "api deploy ${VERSION}" + +echo "[production-api-deploy] 校验 api-server" +( + cd "${SOURCE_DIR}" + sha256sum -c api-server.sha256 +) + +RELEASE_DIR="${RELEASE_ROOT}/${VERSION}" +mkdir -p "${RELEASE_DIR}" +cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server" +chmod +x "${RELEASE_DIR}/api-server" + +if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then + cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json" +fi + +mkdir -p "$(dirname "${CURRENT_LINK}")" +ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" + +echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" +systemctl restart "${SERVICE_NAME}" + +echo "[production-api-deploy] 等待 healthz: ${HEALTH_URL}" +for _ in {1..30}; do + if curl -fsS "${HEALTH_URL}" >/dev/null; then + "${SCRIPT_DIR}/maintenance-off.sh" + DEPLOY_COMPLETED=1 + echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server" + exit 0 + fi + sleep 2 +done + +echo "[production-api-deploy] healthz 检查超时: ${HEALTH_URL}" >&2 +exit 1 diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh new file mode 100644 index 00000000..0929fa96 --- /dev/null +++ b/scripts/deploy/production-stdb-publish.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server local] [--clear-database] + +说明: + 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 + 失败时保留维护模式。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-stdb-publish] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +validate_spacetime_database_name() { + local database="$1" + + if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "[production-stdb-publish] --database 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${database}" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="" +DATABASE="" +SERVER_ALIAS="local" +CLEAR_DATABASE=0 +DEPLOY_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --database) + DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --server) + SERVER_ALIAS="${2:?缺少 --server 的值}" + shift 2 + ;; + --clear-database) + CLEAR_DATABASE=1 + shift + ;; + *) + echo "[production-stdb-publish] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" +require_argument "${DATABASE}" "--database" +validate_spacetime_database_name "${DATABASE}" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" + +if [[ ! -f "${SOURCE_DIR}/spacetime_module.wasm" || ! -f "${SOURCE_DIR}/spacetime_module.wasm.sha256" ]]; then + echo "[production-stdb-publish] 缺少 spacetime_module.wasm 或 spacetime_module.wasm.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then + echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2 + fi + exit "${exit_code}" +} + +trap on_exit EXIT + +"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}" + +echo "[production-stdb-publish] 校验 wasm" +( + cd "${SOURCE_DIR}" + sha256sum -c spacetime_module.wasm.sha256 +) + +PUBLISH_ARGS=( + publish + "${DATABASE}" + --server "${SERVER_ALIAS}" + --bin-path "${SOURCE_DIR}/spacetime_module.wasm" + --yes +) + +if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + PUBLISH_ARGS+=(--clear-database) +fi + +echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}" +spacetime "${PUBLISH_ARGS[@]}" + +"${SCRIPT_DIR}/maintenance-off.sh" +DEPLOY_COMPLETED=1 +echo "[production-stdb-publish] 完成" diff --git a/scripts/deploy/production-web-deploy.sh b/scripts/deploy/production-web-deploy.sh new file mode 100644 index 00000000..c586823f --- /dev/null +++ b/scripts/deploy/production-web-deploy.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-web-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--web-link /srv/genarrative/web] [--current-link /opt/genarrative/current] + +说明: + 校验 web.tar.gz.sha256 后,把 web.tar.gz 解压到 /opt/genarrative/releases//web, + 并更新 /srv/genarrative/web 软链接。如果同版本目录已存在 api-server,则同步更新 /opt/genarrative/current。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-web-deploy] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +SOURCE_DIR="" +VERSION="" +RELEASE_ROOT="/opt/genarrative/releases" +CURRENT_LINK="/opt/genarrative/current" +WEB_LINK="/srv/genarrative/web" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --version) + VERSION="${2:?缺少 --version 的值}" + shift 2 + ;; + --release-root) + RELEASE_ROOT="${2:?缺少 --release-root 的值}" + shift 2 + ;; + --current-link) + CURRENT_LINK="${2:?缺少 --current-link 的值}" + shift 2 + ;; + --web-link) + WEB_LINK="${2:?缺少 --web-link 的值}" + shift 2 + ;; + *) + echo "[production-web-deploy] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-web-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" +VERSION="${VERSION:-$(basename "${SOURCE_DIR}")}" + +if [[ ! "${VERSION}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[production-web-deploy] --version 只能包含数字、字母、点、下划线和短横线: ${VERSION}" >&2 + exit 1 +fi + +if [[ ! -f "${SOURCE_DIR}/web.tar.gz" || ! -f "${SOURCE_DIR}/web.tar.gz.sha256" ]]; then + echo "[production-web-deploy] 缺少 web.tar.gz 或 web.tar.gz.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +echo "[production-web-deploy] 校验 Web 压缩包" +( + cd "${SOURCE_DIR}" + sha256sum -c web.tar.gz.sha256 +) + +RELEASE_DIR="${RELEASE_ROOT}/${VERSION}" +WEB_TARGET="${RELEASE_DIR}/web" +mkdir -p "${RELEASE_DIR}" +rm -rf "${WEB_TARGET}" + +echo "[production-web-deploy] 解压 Web 到: ${WEB_TARGET}" +tar -xzf "${SOURCE_DIR}/web.tar.gz" -C "${RELEASE_DIR}" +test -d "${WEB_TARGET}" + +if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then + cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.web.json" +fi + +mkdir -p "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" +ln -sfn "${WEB_TARGET}" "${WEB_LINK}" +if [[ -x "${RELEASE_DIR}/api-server" ]]; then + ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" +else + echo "[production-web-deploy] ${RELEASE_DIR}/api-server 不存在,仅更新 Web 软链接,保持 current 不变。" +fi + +if command -v nginx >/dev/null 2>&1; then + echo "[production-web-deploy] nginx -t" + nginx -t +fi + +if [[ ! -f "${WEB_TARGET}/index.html" ]]; then + echo "[production-web-deploy] Web smoke test 失败,缺少 index.html: ${WEB_TARGET}" >&2 + exit 1 +fi + +echo "[production-web-deploy] 完成: ${WEB_TARGET}" diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh new file mode 100644 index 00000000..dec8f64e --- /dev/null +++ b/scripts/jenkins-checkout-source.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SOURCE_BRANCH="${SOURCE_BRANCH:-master}" +COMMIT_HASH="${COMMIT_HASH:-}" +GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" +SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}" + +if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then + echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2 + exit 1 +fi + +if [[ "${SOURCE_BRANCH}" == /* || "${SOURCE_BRANCH}" == */ || "${SOURCE_BRANCH}" == *..* ]]; then + echo "[jenkins-checkout-source] SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号: ${SOURCE_BRANCH}" >&2 + exit 1 +fi + +if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + echo "[jenkins-checkout-source] COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash: ${COMMIT_HASH}" >&2 + exit 1 +fi + +if [[ -n "${GIT_REMOTE_URL}" ]]; then + git remote set-url origin "${GIT_REMOTE_URL}" +fi + +git reset --hard HEAD +git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + +if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then + git fetch --unshallow --tags || true +fi + +git cat-file -e "refs/remotes/origin/${SOURCE_BRANCH}^{commit}" + +if [[ -n "${COMMIT_HASH}" ]]; then + git cat-file -e "${COMMIT_HASH}^{commit}" + RESOLVED_COMMIT="$(git rev-parse "${COMMIT_HASH}^{commit}")" + if ! git merge-base --is-ancestor "${RESOLVED_COMMIT}" "refs/remotes/origin/${SOURCE_BRANCH}"; then + echo "[jenkins-checkout-source] 指定 commit 不属于 origin/${SOURCE_BRANCH}: ${RESOLVED_COMMIT}" >&2 + exit 1 + fi +else + RESOLVED_COMMIT="$(git rev-parse "refs/remotes/origin/${SOURCE_BRANCH}^{commit}")" +fi + +git checkout --detach "${RESOLVED_COMMIT}" +git reset --hard HEAD +git clean -fd + +printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}" +echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}" diff --git a/server-rs/.cargo/config.toml b/server-rs/.cargo/config.toml new file mode 100644 index 00000000..525e9b4e --- /dev/null +++ b/server-rs/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 07b28386..86070dca 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -52,7 +52,3 @@ http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } sha1 = "0.10" tower = { version = "0.5", features = ["util"] } - -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=lld"] # Ubuntu 22+ 自带 lld diff --git a/生产部署计划.md b/生产部署计划.md deleted file mode 100644 index c5f72169..00000000 --- a/生产部署计划.md +++ /dev/null @@ -1,316 +0,0 @@ -# 生产部署计划 - -更新时间:2026-05-02 - -## 目标 - -将当前部署方式调整为单机生产推荐方案:生产运行路径不使用 Docker,不再使用旧的一体化启动脚本,由 systemd 托管 SpacetimeDB 与 Rust `api-server`,由 Nginx 托管主站、后台前端与必要反向代理。 - -本计划用于重新创建 Jenkins 流水线、服务器环境配置、网站发布、`api-server` 发布、SpacetimeDB 模块发布,以及数据库人工导入导出流程。 - -## 生产架构 - -- Nginx 作为唯一公网入口,负责 HTTPS、静态站点、后台静态页面、维护页与 `/admin/api/` 反向代理。 -- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3000`,数据根目录为 `/stdb`。 -- Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。 -- 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。 -- 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。 - -## 服务器目录 - -- `/opt/genarrative/releases//`:每次发布的完整版本目录。 -- `/opt/genarrative/current`:指向当前生效版本的软链接。 -- `/srv/genarrative/web`:指向 `/opt/genarrative/current/web`,供 Nginx 托管静态站点。 -- `/etc/genarrative/api-server.env`:`api-server` 生产环境变量文件。 -- `/var/lib/genarrative/maintenance/enabled`:维护模式开关文件。 -- `/stdb`:SpacetimeDB 程序、配置与数据根目录。 - -## 生产密钥 - -`/etc/genarrative/api-server.env` 中的生产密钥指所有只能存在于生产服务器、不能进入 Git、不能进入构建产物的敏感配置。典型内容包括: - -- LLM 或第三方服务 API Key。 -- 短信服务 Access Key 与 Secret。 -- 后台登录、会话、签名、加密相关密钥。 -- 生产 SpacetimeDB 地址与数据库名。 -- 只允许生产使用的回调地址、白名单或内部令牌。 - -该文件由服务器配置流水线或人工初始化创建,权限建议为 `root:genarrative`、`0640`。Jenkins 构建任务不能读取该文件;只有生产发布或服务启动需要读取。 - -## systemd 服务 - -### SpacetimeDB - -- 服务名:`spacetimedb.service` -- 运行用户:`spacetimedb` -- 工作目录:`/stdb` -- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000` -- 对外暴露:默认不直接暴露公网端口。 - -该方案与 SpacetimeDB 官方自托管文档一致:使用 Ubuntu、专用用户、`/stdb` 根目录、systemd 服务和 Nginx。 - -### api-server - -- 服务名:`genarrative-api.service` -- 运行用户:`genarrative` -- 工作目录:`/opt/genarrative/current` -- 可执行文件:`/opt/genarrative/current/api-server` -- 环境文件:`/etc/genarrative/api-server.env` -- 监听地址:`127.0.0.1:8082` - -`api-server` 不放入 Docker,也不直接暴露公网端口。发布时替换版本目录并重启 `genarrative-api.service`。 - -## Nginx 规则 - -只保留必要入口: - -- `/`:主站静态页面。 -- `/admin/`:后台前端静态页面,后台构建时使用 `/admin/` 作为 base path。 -- `/admin/api/`:反向代理到 `http://127.0.0.1:8082/admin/api/`。 -- HTTP 到 HTTPS:只保留 301 重定向。 -- `/maintenance.html`:维护中页面。 - -移除这些公网反向代理: - -- `/api/*` -- `/generated-*` -- 公网 `/healthz` -- 其他旧的一体化 web server 代理入口。 - -SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。 - -## 维护模式 - -维护模式由 `/var/lib/genarrative/maintenance/enabled` 控制: - -- 文件存在:进入维护模式。 -- 文件不存在:退出维护模式。 - -行为: - -- 网站静态资源发布不进入维护模式。 -- `api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。 -- 普通页面在维护模式下展示 `/maintenance.html`。 -- `/admin/api/*` 在维护模式下返回 503。 -- 静态资源仍允许访问,避免维护页样式和资源加载失败。 -- 发布成功后自动解除维护模式。 -- 发布失败时保持维护模式,并通过邮件通知人工处理。 - -## 构建产物 - -每次构建产物按版本号归档: - -```text -build// -├─ web/ -│ ├─ index.html -│ ├─ assets/ -│ ├─ maintenance.html -│ └─ admin/ -├─ api-server -├─ spacetime_module.wasm -├─ scripts/ -│ ├─ spacetime-publish-prod.sh -│ ├─ database-export.sh -│ ├─ database-import.sh -│ ├─ maintenance-on.sh -│ ├─ maintenance-off.sh -│ └─ maintenance-status.sh -├─ deploy/ -│ ├─ systemd/ -│ │ ├─ spacetimedb.service -│ │ └─ genarrative-api.service -│ ├─ nginx/ -│ │ ├─ genarrative.conf -│ │ └─ snippets/maintenance.conf -│ └─ env/api-server.env.example -└─ README.md -``` - -不再生成旧产物: - -- `web-server.mjs` -- 旧的一体化 `start.sh` -- 旧的一体化 `stop.sh` - -## Jenkins 节点 - -Jenkins 可运行在 Windows 或其他机器上,但构建与发布动作使用 Linux agent。 - -### 开发/构建实例 - -- 节点名:`genarrative-dev` -- 标签:`genarrative-linux dev build` -- 用途:拉代码、安装依赖、构建主站、构建后台、构建 `api-server`、构建 SpacetimeDB wasm、归档产物。 - -### 生产/发布实例 - -- 节点名:`genarrative-prod` -- 标签:`genarrative-linux prod deploy` -- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 - -### SSH PEM 凭证 - -在 Jenkins 中使用 `SSH Username with private key` 类型添加 PEM 私钥: - -- `genarrative-dev-ssh-key`:开发/构建实例 SSH 凭证。 -- `genarrative-prod-ssh-key`:生产/发布实例 SSH 凭证。 - -推荐使用非 root 用户,例如 `jenkins`。该用户只通过 sudoers 获得必要命令权限,例如 `systemctl restart genarrative-api`、`nginx -t`、维护脚本、发布目录切换等。 - -## Jenkins 流水线 - -计划重新创建以下流水线: - -1. `Genarrative-Server-Provision` -2. `Genarrative-Web-Build` -3. `Genarrative-Web-Deploy` -4. `Genarrative-Api-Build` -5. `Genarrative-Api-Deploy` -6. `Genarrative-Stdb-Module-Build` -7. `Genarrative-Stdb-Module-Publish` -8. `Genarrative-Database-Export` -9. `Genarrative-Database-Import` -10. `Genarrative-Full-Build-And-Deploy` - -构建流水线运行在 `build && dev`,发布、导入导出和服务器配置流水线运行在 `deploy && prod`。 - -构建流水线支持参数 `PUBLISH_AFTER_BUILD`: - -- `false`:只构建并归档产物。 -- `true`:构建成功后触发对应发布流水线。 - -发布流水线必须从归档产物获取文件,不依赖构建 workspace 的本地状态。 - -## 流水线职责 - -### Genarrative-Server-Provision - -用于生产服务器一次性或低频配置: - -- 创建 `spacetimedb`、`genarrative` 等系统用户。 -- 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 -- 安装或更新 SpacetimeDB。 -- 安装 systemd unit。 -- 安装 Nginx 配置和维护模式 snippet。 -- 执行 `nginx -t`。 -- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 - -该流水线属于高风险操作,默认要求人工确认后执行。 - -### Web Build / Deploy - -构建: - -- 构建主站静态文件。 -- 构建后台前端,base path 为 `/admin/`。 -- 生成或复制 `maintenance.html`。 -- 归档 `web/` 产物。 - -发布: - -- 解包到 `/opt/genarrative/releases//web`。 -- 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 -- 执行 Nginx 配置测试和静态页面 smoke test。 -- 不进入维护模式。 - -### Api Build / Deploy - -构建: - -- 编译 Rust `api-server`。 -- 归档单一可执行文件和必要运行说明。 - -发布: - -- 进入维护模式。 -- 解包到 `/opt/genarrative/releases//api-server`。 -- 更新 `/opt/genarrative/current`。 -- 重启 `genarrative-api.service`。 -- 检查本机 `/healthz`。 -- 成功后解除维护模式。 -- 失败时保留维护模式并发邮件。 - -### Stdb Module Build / Publish - -构建: - -- 使用 `spacetime build` 构建 `spacetime_module.wasm`。 -- 归档 wasm 与发布脚本。 - -发布: - -- 进入维护模式。 -- 将 wasm 上传到生产实例。 -- 在生产实例本机执行 `spacetime publish -s local --bin-path spacetime_module.wasm `。 -- 成功后执行必要 smoke test。 -- 成功后解除维护模式。 -- 失败时保留维护模式并发邮件。 - -## 数据库导出与导入 - -### 导出 - -`Genarrative-Database-Export` 用于人工导出生产数据: - -- 运行在 `deploy && prod`。 -- 进入维护模式,避免导出期间继续写入。 -- 从本机 SpacetimeDB 导出指定数据库数据。 -- 产物归档到 Jenkins,并可额外保存到服务器备份目录。 -- 成功后解除维护模式。 -- 失败时保留维护模式并邮件通知。 - -### 导入 - -`Genarrative-Database-Import` 用于人工导入或恢复数据: - -- 运行在 `deploy && prod`。 -- 必须要求人工确认目标数据库、导入文件和是否覆盖。 -- 进入维护模式。 -- 导入前先生成一次安全备份。 -- 执行导入。 -- 执行数据校验和服务 smoke test。 -- 成功后解除维护模式。 -- 失败时保留维护模式并邮件通知。 - -数据库表结构变更必须同步检查并更新 `migration.rs`,不能只发布 wasm。 - -## 全量构建并发布 - -`Genarrative-Full-Build-And-Deploy` 顺序: - -1. 触发 `Genarrative-Web-Build`。 -2. 触发 `Genarrative-Api-Build`。 -3. 触发 `Genarrative-Stdb-Module-Build`。 -4. 触发 `Genarrative-Stdb-Module-Publish`。 -5. 触发 `Genarrative-Api-Deploy`。 -6. 触发 `Genarrative-Web-Deploy`。 -7. 执行生产 smoke test。 - -网站最后发布,避免后台或主站提前指向尚未完成发布的后端能力。 - -## 回滚 - -- 网站回滚:将 `/srv/genarrative/web` 或 `/opt/genarrative/current` 切回上一版本并 reload Nginx。 -- `api-server` 回滚:将 `/opt/genarrative/current` 切回上一版本并重启 `genarrative-api.service`。 -- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。 -- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。 - -## 待落地文件 - -后续工程落地时需要新增或改造: - -- `deploy/systemd/spacetimedb.service` -- `deploy/systemd/genarrative-api.service` -- `deploy/nginx/genarrative.conf` -- `deploy/nginx/snippets/maintenance.conf` -- `deploy/env/api-server.env.example` -- `scripts/deploy/maintenance-on.sh` -- `scripts/deploy/maintenance-off.sh` -- `scripts/deploy/maintenance-status.sh` -- 新 Jenkinsfile 或 Job DSL,用于上述 10 条流水线。 -- 更新旧部署文档,标记旧一体化脚本为废弃或迁移对象。 - -## 参考 - -- SpacetimeDB 官方自托管文档:https://spacetimedb.com/docs/how-to/deploy/self-hosting/ From ae33f10f176e83b33eddee6033ec07aca8103f45 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:28:07 +0800 Subject: [PATCH 03/25] Remove unsupported Jenkins timestamp option --- jenkins/Jenkinsfile.production-api-build | 1 - jenkins/Jenkinsfile.production-api-deploy | 1 - jenkins/Jenkinsfile.production-database-export | 1 - jenkins/Jenkinsfile.production-database-import | 1 - jenkins/Jenkinsfile.production-full-build-and-deploy | 1 - jenkins/Jenkinsfile.production-server-provision | 1 - jenkins/Jenkinsfile.production-stdb-module-build | 1 - jenkins/Jenkinsfile.production-stdb-module-publish | 1 - jenkins/Jenkinsfile.production-web-build | 1 - jenkins/Jenkinsfile.production-web-deploy | 1 - 10 files changed, 10 deletions(-) diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 85156be8..bac773c9 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -5,7 +5,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index d5601910..f48e6574 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 94294431..965e3e3f 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 8cb92c53..707cdcc3 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 47d59db6..7cb67d78 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index a3d11ffe..9a75668d 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index dea4ba7b..8139db4c 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -5,7 +5,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 2eaed5d0..c5c83d82 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index c19f5e49..3f269eab 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -5,7 +5,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index b61fc021..2c799d7e 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -3,7 +3,6 @@ pipeline { options { disableConcurrentBuilds() - timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } From 23cb37c18af42953d7546bd2e2fcef00f4a0b2a8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:29:58 +0800 Subject: [PATCH 04/25] Revert "Remove unsupported Jenkins timestamp option" This reverts commit ae33f10f176e83b33eddee6033ec07aca8103f45. --- jenkins/Jenkinsfile.production-api-build | 1 + jenkins/Jenkinsfile.production-api-deploy | 1 + jenkins/Jenkinsfile.production-database-export | 1 + jenkins/Jenkinsfile.production-database-import | 1 + jenkins/Jenkinsfile.production-full-build-and-deploy | 1 + jenkins/Jenkinsfile.production-server-provision | 1 + jenkins/Jenkinsfile.production-stdb-module-build | 1 + jenkins/Jenkinsfile.production-stdb-module-publish | 1 + jenkins/Jenkinsfile.production-web-build | 1 + jenkins/Jenkinsfile.production-web-deploy | 1 + 10 files changed, 10 insertions(+) diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index bac773c9..85156be8 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -5,6 +5,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index f48e6574..d5601910 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 965e3e3f..94294431 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 707cdcc3..8cb92c53 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 7cb67d78..47d59db6 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 9a75668d..a3d11ffe 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 8139db4c..dea4ba7b 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -5,6 +5,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index c5c83d82..2eaed5d0 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 3f269eab..c19f5e49 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -5,6 +5,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 2c799d7e..b61fc021 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -3,6 +3,7 @@ pipeline { options { disableConcurrentBuilds() + timestamps() buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } From a53167c8727cdd0dea215340d1ef283f1717213d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:30:41 +0800 Subject: [PATCH 05/25] Keep timestamped production Jenkins logs --- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index efc656fa..b458d5ca 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -248,7 +248,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 -发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Jenkinsfile 保留 `timestamps()` 以保证日志可审计,Jenkins 还需要安装并启用 Timestamper 插件。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 所有发布流水线必须提供 `DEPLOY_TARGET` 参数,用于选择逻辑部署目标: From 06d6f7716ef92f1441d3cab38c37c884cb804e0a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:34:11 +0800 Subject: [PATCH 06/25] Use compatible timestamp wrapper in Jenkins pipelines --- jenkins/Jenkinsfile.production-api-build | 3 ++- jenkins/Jenkinsfile.production-api-deploy | 3 ++- jenkins/Jenkinsfile.production-database-export | 3 ++- jenkins/Jenkinsfile.production-database-import | 3 ++- jenkins/Jenkinsfile.production-full-build-and-deploy | 3 ++- jenkins/Jenkinsfile.production-server-provision | 3 ++- jenkins/Jenkinsfile.production-stdb-module-build | 3 ++- jenkins/Jenkinsfile.production-stdb-module-publish | 3 ++- jenkins/Jenkinsfile.production-web-build | 3 ++- jenkins/Jenkinsfile.production-web-deploy | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 85156be8..59ca6689 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -5,7 +5,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index d5601910..c964eac0 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 94294431..d4bb49ad 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 8cb92c53..82276882 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 47d59db6..efa77f33 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index a3d11ffe..f423091a 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index dea4ba7b..fec719b2 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -5,7 +5,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 2eaed5d0..a8a4ae0e 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index c19f5e49..4e09274d 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -5,7 +5,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index b61fc021..5ca0b6e5 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -3,7 +3,8 @@ pipeline { options { disableConcurrentBuilds() - timestamps() + skipDefaultCheckout(true) + wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } From 0c9e58b75a95f00aeaa93f43a3322e8596b13e3a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 19:43:17 +0800 Subject: [PATCH 07/25] Enable Jenkins global timestamps for production pipelines --- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 2 +- jenkins/Jenkinsfile.production-api-build | 1 - jenkins/Jenkinsfile.production-api-deploy | 1 - jenkins/Jenkinsfile.production-database-export | 1 - jenkins/Jenkinsfile.production-database-import | 1 - jenkins/Jenkinsfile.production-full-build-and-deploy | 1 - jenkins/Jenkinsfile.production-server-provision | 1 - jenkins/Jenkinsfile.production-stdb-module-build | 1 - jenkins/Jenkinsfile.production-stdb-module-publish | 1 - jenkins/Jenkinsfile.production-web-build | 1 - jenkins/Jenkinsfile.production-web-deploy | 1 - 11 files changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index b458d5ca..816cacf9 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -248,7 +248,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 -发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Jenkinsfile 保留 `timestamps()` 以保证日志可审计,Jenkins 还需要安装并启用 Timestamper 插件。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 所有发布流水线必须提供 `DEPLOY_TARGET` 参数,用于选择逻辑部署目标: diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 59ca6689..459f4c6e 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -6,7 +6,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index c964eac0..e3efb613 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index d4bb49ad..c9f89452 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 82276882..6a62955f 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index efa77f33..ad10f353 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index f423091a..d1929c90 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index fec719b2..a3cb38da 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -6,7 +6,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index a8a4ae0e..39c0cc48 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 4e09274d..86c623f8 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -6,7 +6,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 5ca0b6e5..0230921a 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -4,7 +4,6 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - wrap([$class: 'TimestamperBuildWrapper']) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } From 64fda2a67742328761dbf39c23e0c4358b478297 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 20:01:35 +0800 Subject: [PATCH 08/25] Handle empty commit hash in Jenkins checkout --- jenkins/Jenkinsfile.production-api-build | 4 ++-- jenkins/Jenkinsfile.production-api-deploy | 4 ++-- jenkins/Jenkinsfile.production-database-export | 4 ++-- jenkins/Jenkinsfile.production-database-import | 4 ++-- jenkins/Jenkinsfile.production-full-build-and-deploy | 4 ++-- jenkins/Jenkinsfile.production-server-provision | 4 ++-- jenkins/Jenkinsfile.production-stdb-module-build | 4 ++-- jenkins/Jenkinsfile.production-stdb-module-publish | 4 ++-- jenkins/Jenkinsfile.production-web-build | 4 ++-- jenkins/Jenkinsfile.production-web-deploy | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 459f4c6e..83ec02e7 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -43,8 +43,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index e3efb613..d9310fd1 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -64,8 +64,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index c9f89452..2a54712b 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -92,8 +92,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 6a62955f..1e7d3c43 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -150,8 +150,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index ad10f353..e22ebc40 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -47,8 +47,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index d1929c90..98abf53f 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -69,8 +69,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index a3cb38da..89bd1112 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -44,8 +44,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 39c0cc48..4465aea5 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -66,8 +66,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 86c623f8..2e8d65a2 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -38,8 +38,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 0230921a..8cb750b5 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -63,8 +63,8 @@ pipeline { bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh From 7160e909091fefe5275bf2173a4211ffd57fcf55 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 20:25:01 +0800 Subject: [PATCH 09/25] Guard server provision nginx install --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 10 +- .../Jenkinsfile.production-server-provision | 108 ++++++++++++++++-- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 816cacf9..1ff8147f 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -350,12 +350,16 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 - 安装或更新 SpacetimeDB。 - 安装 systemd unit。 -- 安装 Nginx 配置和维护模式 snippet。 -- 执行 `nginx -t`。 +- 可选安装 Nginx 配置和维护模式 snippet。 +- 安装 Nginx 配置时执行 `nginx -t`。 - 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 该流水线属于高风险操作,默认要求人工确认后执行。 -已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、Nginx 配置并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 +已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 + +首次真实初始化默认保持 `INSTALL_NGINX_CONFIG=false`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。等真实域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 从 `genarrative.example.com` 改成真实域名,并设置 `INSTALL_NGINX_CONFIG=true` 安装 Nginx HTTPS 配置。流水线会拒绝在真实初始化中用占位域名写入 Nginx 配置,也会在证书缺失时提前失败。 + +若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `INSTALL_NGINX_CONFIG=false` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 ### Web Build / Deploy diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 98abf53f..d40f1700 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -26,7 +26,7 @@ pipeline { string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') - booleanParam(name: 'INSTALL_NGINX_CONFIG', defaultValue: true, description: '安装 Nginx 配置并执行 nginx -t') + booleanParam(name: 'INSTALL_NGINX_CONFIG', defaultValue: false, description: '安装 Nginx 配置并执行 nginx -t;首次初始化且证书未准备好时保持关闭') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') } @@ -49,6 +49,9 @@ pipeline { if (!params.SPACETIME_BIN_SOURCE?.trim()) { error('SPACETIME_BIN_SOURCE 不能为空。') } + if (!params.DRY_RUN && params.INSTALL_NGINX_CONFIG && params.SERVER_NAME?.trim() == 'genarrative.example.com') { + error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 INSTALL_NGINX_CONFIG=false。') + } } } } @@ -124,6 +127,98 @@ pipeline { deploy/env/api-server.env.example } + validate_nginx_tls() { + local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" + if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then + echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 INSTALL_NGINX_CONFIG=false。" >&2 + exit 1 + fi + if [[ ! -f "${cert_dir}/fullchain.pem" || ! -f "${cert_dir}/privkey.pem" ]]; then + echo "[server-provision] 未找到 Nginx HTTPS 证书: ${cert_dir}/fullchain.pem 或 ${cert_dir}/privkey.pem" >&2 + echo "[server-provision] 请先完成证书申请,或首次初始化时设置 INSTALL_NGINX_CONFIG=false,避免写入无法通过 nginx -t 的配置。" >&2 + exit 1 + fi + } + + install_nginx_config_with_rollback() { + local config_target="/etc/nginx/conf.d/genarrative.conf" + local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" + local rendered_config rendered_snippet config_backup snippet_backup + local had_config="false" + local had_snippet="false" + + run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d + echo "+ render deploy/nginx/genarrative.conf -> ${config_target}" + echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ nginx -t" + return + fi + + validate_nginx_tls + rendered_config="$(mktemp)" + rendered_snippet="$(mktemp)" + config_backup="$(mktemp)" + snippet_backup="$(mktemp)" + render_nginx_config >"${rendered_config}" + cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" + + if [[ -f "${config_target}" ]]; then + cp -p "${config_target}" "${config_backup}" + had_config="true" + fi + if [[ -f "${snippet_target}" ]]; then + cp -p "${snippet_target}" "${snippet_backup}" + had_snippet="true" + fi + + install -m 0644 "${rendered_config}" "${config_target}" + install -m 0644 "${rendered_snippet}" "${snippet_target}" + + if ! nginx -t; then + echo "[server-provision] nginx -t 失败,恢复写入前的 Nginx 配置。" >&2 + if [[ "${had_config}" == "true" ]]; then + cp -p "${config_backup}" "${config_target}" + else + rm -f "${config_target}" + fi + if [[ "${had_snippet}" == "true" ]]; then + cp -p "${snippet_backup}" "${snippet_target}" + else + rm -f "${snippet_target}" + fi + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" + exit 1 + fi + + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" + } + + cleanup_placeholder_nginx_config() { + local config_target="/etc/nginx/conf.d/genarrative.conf" + local disabled_target + + if [[ ! -f "${config_target}" ]]; then + return + fi + + if ! grep -q "/etc/letsencrypt/live/genarrative.example.com/" "${config_target}"; then + return + fi + + disabled_target="${config_target}.disabled-placeholder-$(date +%Y%m%d%H%M%S)" + echo "[server-provision] 发现上一轮初始化留下的占位域名 Nginx 配置,禁用: ${config_target} -> ${disabled_target}" + if [[ "${DRY_RUN}" != "true" ]]; then + mv "${config_target}" "${disabled_target}" + if command -v nginx >/dev/null 2>&1; then + if ! nginx -t; then + echo "[server-provision] 占位配置已禁用,但 nginx -t 仍失败;请检查其他 Nginx 配置。" >&2 + fi + fi + fi + } + escape_sed_replacement() { printf "%s" "$1" | sed "s/[&|]/\\\\&/g" } @@ -205,14 +300,9 @@ pipeline { fi if [[ "${INSTALL_NGINX_CONFIG}" == "true" ]]; then - run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d - echo "+ render deploy/nginx/genarrative.conf -> /etc/nginx/conf.d/genarrative.conf" - if [[ "${DRY_RUN}" != "true" ]]; then - render_nginx_config >/etc/nginx/conf.d/genarrative.conf - chmod 0644 /etc/nginx/conf.d/genarrative.conf - fi - install_file deploy/nginx/snippets/genarrative-maintenance.conf /etc/nginx/snippets/genarrative-maintenance.conf 0644 - run_cmd nginx -t + install_nginx_config_with_rollback + else + cleanup_placeholder_nginx_config fi run_cmd systemctl daemon-reload From d48916157ba4ec20466b5099b3d4bd5b0aae20cd Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 20:42:47 +0800 Subject: [PATCH 10/25] Make production builds bootstrap agent dependencies --- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 2 ++ jenkins/Jenkinsfile.production-api-build | 4 ++++ jenkins/Jenkinsfile.production-full-build-and-deploy | 2 ++ jenkins/Jenkinsfile.production-stdb-module-build | 4 ++++ jenkins/Jenkinsfile.production-web-build | 2 +- 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 1ff8147f..b8d12225 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -307,6 +307,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 并发与清理规则: - Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,或对共享 `CARGO_TARGET_DIR` 加 Jenkins `lock`,避免多个同包 release 构建同时写入同一最终产物路径。 +- 如果 Linux agent 未安装 `sccache`,Rust 构建流水线必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。 - 生产发布流水线只能消费 `build//` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 - `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 - 对 `/var/cache/genarrative-build/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 @@ -366,6 +367,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 构建: - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。 +- 默认执行 `npm ci` 安装前端依赖,确保 Jenkins 新 workspace 中存在 `vite` 等构建工具。 - 构建主站静态文件。 - 构建后台前端,base path 为 `/admin/`。 - 生成或复制 `maintenance.html`。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 83ec02e7..40a29e32 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -63,6 +63,10 @@ pipeline { bash -lc ' set -euo pipefail mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + if ! command -v sccache >/dev/null 2>&1; then + echo "[api-build] 未找到 sccache,改用 rustc 直接构建。" + unset RUSTC_WRAPPER + fi SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ npm run build:production-release -- --component api-server --name "${EFFECTIVE_BUILD_VERSION}" ' diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index e22ebc40..1b398f37 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -19,6 +19,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名') @@ -76,6 +77,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + booleanParam(name: 'RUN_NPM_CI', value: params.RUN_NPM_CI), ] env.WEB_BUILD_NUMBER = webRun.number.toString() } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 89bd1112..be40cdae 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -64,6 +64,10 @@ pipeline { bash -lc ' set -euo pipefail mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + if ! command -v sccache >/dev/null 2>&1; then + echo "[stdb-build] 未找到 sccache,改用 rustc 直接构建。" + unset RUSTC_WRAPPER + fi SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}" ' diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 2e8d65a2..b6452fe0 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -17,7 +17,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否执行 npm ci') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') From e61b1a1586114b5866efcccd094476713bb5c010 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 21:16:54 +0800 Subject: [PATCH 11/25] Split production cargo caches by component --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 32 ++++++++++++++----- jenkins/Jenkinsfile.production-api-build | 6 ++-- .../Jenkinsfile.production-stdb-module-build | 6 ++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index b8d12225..eaf18965 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -264,20 +264,34 @@ Jenkins 在 agent 上执行构建时会为不同 Job 或不同构建创建独立 如果构建 agent 使用 `root` 账户执行,缓存目录不应写死为 `/var/lib/jenkins`。推荐优先使用单独数据盘,例如 `/data/jenkins-cache/genarrative/`;如果没有数据盘,可使用 `/var/cache/genarrative-build/`: ```bash -mkdir -p /var/cache/genarrative-build/{cargo-home,cargo-target,sccache} +mkdir -p /var/cache/genarrative-build/{api-server,stdb-module} chmod 700 /var/cache/genarrative-build ``` -Rust 构建流水线建议统一设置: +API 构建流水线建议设置: ```groovy environment { - CARGO_HOME = '/var/cache/genarrative-build/cargo-home' - CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_HOME = '/var/cache/genarrative-build/api-server/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/api-server/cargo-target/prod-release' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' - SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/api-server/sccache' + SCCACHE_CACHE_SIZE = '30G' +} +``` + +Stdb module 构建流水线建议设置: + +```groovy +environment { + CARGO_HOME = '/var/cache/genarrative-build/stdb-module/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/stdb-module/cargo-target/prod-release' + CARGO_INCREMENTAL = '0' + + RUSTC_WRAPPER = 'sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/stdb-module/sccache' SCCACHE_CACHE_SIZE = '30G' } ``` @@ -286,16 +300,18 @@ environment { ```groovy environment { - CARGO_HOME = '/data/jenkins-cache/genarrative/cargo-home' - CARGO_TARGET_DIR = '/data/jenkins-cache/genarrative/cargo-target/prod-release' + CARGO_HOME = '/data/jenkins-cache/genarrative//cargo-home' + CARGO_TARGET_DIR = '/data/jenkins-cache/genarrative//cargo-target/prod-release' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' - SCCACHE_DIR = '/data/jenkins-cache/genarrative/sccache' + SCCACHE_DIR = '/data/jenkins-cache/genarrative//sccache' SCCACHE_CACHE_SIZE = '30G' } ``` +其中 `` 使用 `api-server` 或 `stdb-module`。API 与 Stdb module 并行构建时不能共享同一个 `CARGO_HOME` 或 `CARGO_TARGET_DIR`,否则容易在 Cargo package cache 或 target 目录上出现 `Blocking waiting for file lock on package cache` 等锁等待。 + `scripts/build-production-release.sh` 必须尊重 `CARGO_TARGET_DIR`,不能硬编码从 `server-rs/target/` 拷贝 Rust 产物。脚本中的产物路径应按以下口径计算: ```bash diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 40a29e32..4e182083 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -11,11 +11,11 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - CARGO_HOME = '/var/cache/genarrative-build/cargo-home' - CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_HOME = '/var/cache/genarrative-build/api-server/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/api-server/cargo-target/prod-release' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' - SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/api-server/sccache' SCCACHE_CACHE_SIZE = '30G' } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index be40cdae..7924f019 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -11,11 +11,11 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - CARGO_HOME = '/var/cache/genarrative-build/cargo-home' - CARGO_TARGET_DIR = '/var/cache/genarrative-build/cargo-target/prod-release' + CARGO_HOME = '/var/cache/genarrative-build/stdb-module/cargo-home' + CARGO_TARGET_DIR = '/var/cache/genarrative-build/stdb-module/cargo-target/prod-release' CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' - SCCACHE_DIR = '/var/cache/genarrative-build/sccache' + SCCACHE_DIR = '/var/cache/genarrative-build/stdb-module/sccache' SCCACHE_CACHE_SIZE = '30G' } From 6c519970b4bb62bcd846668758ae0549d270ab1c Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 21:20:27 +0800 Subject: [PATCH 12/25] Add development HTTP nginx provision mode --- deploy/nginx/genarrative-dev-http.conf | 87 +++++++++++++++++++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 20 +++-- .../Jenkinsfile.production-server-provision | 50 ++++++++--- scripts/build-production-release.sh | 2 +- 4 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 deploy/nginx/genarrative-dev-http.conf diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf new file mode 100644 index 00000000..dd7e27ed --- /dev/null +++ b/deploy/nginx/genarrative-dev-http.conf @@ -0,0 +1,87 @@ +# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。 +# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。 +# 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。 +server { + listen 80; + server_name genarrative.example.com; + + root /srv/genarrative/web; + index index.html; + + include /etc/nginx/snippets/genarrative-maintenance.conf; + + location ^~ /admin/api/ { + default_type application/json; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://127.0.0.1:8082/admin/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + } + + location = /admin { + return 301 /admin/; + } + + location ^~ /admin/assets/ { + try_files $uri =404; + } + + location ^~ /admin/ { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /admin/index.html; + } + + location ^~ /assets/ { + try_files $uri =404; + } + + # 开发服也不恢复旧一体化 API、生成资源代理和健康检查公网入口。 + location ~ ^/(api|generated-|healthz) { + return 404; + } + + # 仅开放前端 SpacetimeDB SDK 运行所需的最小公网路由。 + location ~ ^/v1/database/[^/]+/subscribe$ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + location ^~ /v1/identity { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + location ^~ /v1/ { + return 404; + } + + location / { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index eaf18965..97bd5ee3 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -9,6 +9,7 @@ - `deploy/systemd/spacetimedb.service` - `deploy/systemd/genarrative-api.service` - `deploy/nginx/genarrative.conf` +- `deploy/nginx/genarrative-dev-http.conf` - `deploy/nginx/snippets/genarrative-maintenance.conf` - `deploy/env/api-server.env.example` - `scripts/deploy/maintenance-on.sh` @@ -93,12 +94,12 @@ ## Nginx 规则 -只保留必要入口: +生产正式入口只保留必要路由: - `/`:主站静态页面。 - `/admin/`:后台前端静态页面,后台构建时使用 `/admin/` 作为 base path。 - `/admin/api/`:反向代理到 `http://127.0.0.1:8082/admin/api/`。 -- HTTP 到 HTTPS:只保留 301 重定向。 +- HTTP 到 HTTPS:`production-https` 模式只保留 301 重定向。 - `/maintenance.html`:维护中页面。 移除这些公网反向代理: @@ -110,6 +111,11 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。 +Nginx 配置文件分为两类: + +- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。 +- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/api/*`、`/generated-*` 或公网 `/healthz`。 + ## 维护模式 维护模式由 `/var/lib/genarrative/maintenance/enabled` 控制: @@ -164,6 +170,7 @@ build// │ │ └─ genarrative-api.service │ ├─ nginx/ │ │ ├─ genarrative.conf +│ │ ├─ genarrative-dev-http.conf │ │ └─ snippets/genarrative-maintenance.conf │ └─ env/api-server.env.example └─ README.md @@ -322,11 +329,11 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 并发与清理规则: -- Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,或对共享 `CARGO_TARGET_DIR` 加 Jenkins `lock`,避免多个同包 release 构建同时写入同一最终产物路径。 +- 同一个 Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,避免同一组件的多个 release 构建同时写入同一最终产物路径。 - 如果 Linux agent 未安装 `sccache`,Rust 构建流水线必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。 - 生产发布流水线只能消费 `build//` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 - `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 -- 对 `/var/cache/genarrative-build/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 +- 对 `/var/cache/genarrative-build/*/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 - Jenkins Job 必须配置构建记录和归档产物保留策略,避免历史 release 包长期堆积。 ### 统一源码版本参数 @@ -374,9 +381,9 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 -首次真实初始化默认保持 `INSTALL_NGINX_CONFIG=false`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。等真实域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 从 `genarrative.example.com` 改成真实域名,并设置 `INSTALL_NGINX_CONFIG=true` 安装 Nginx HTTPS 配置。流水线会拒绝在真实初始化中用占位域名写入 Nginx 配置,也会在证书缺失时提前失败。 +首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。 -若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `INSTALL_NGINX_CONFIG=false` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 +若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 ### Web Build / Deploy @@ -510,6 +517,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - [x] `deploy/systemd/spacetimedb.service` - [x] `deploy/systemd/genarrative-api.service` - [x] `deploy/nginx/genarrative.conf` +- [x] `deploy/nginx/genarrative-dev-http.conf` - [x] `deploy/nginx/snippets/genarrative-maintenance.conf` - [x] `deploy/env/api-server.env.example` - [x] `scripts/deploy/maintenance-on.sh` diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index d40f1700..47982be7 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -26,7 +26,7 @@ pipeline { string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') - booleanParam(name: 'INSTALL_NGINX_CONFIG', defaultValue: false, description: '安装 Nginx 配置并执行 nginx -t;首次初始化且证书未准备好时保持关闭') + choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') } @@ -49,8 +49,15 @@ pipeline { if (!params.SPACETIME_BIN_SOURCE?.trim()) { error('SPACETIME_BIN_SOURCE 不能为空。') } - if (!params.DRY_RUN && params.INSTALL_NGINX_CONFIG && params.SERVER_NAME?.trim() == 'genarrative.example.com') { - error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 INSTALL_NGINX_CONFIG=false。') + def nginxMode = params.NGINX_CONFIG_MODE?.trim() + if (!(nginxMode in ['none', 'production-https', 'development-http'])) { + error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}") + } + if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { + error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') + } + if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { + error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') } } } @@ -116,10 +123,14 @@ pipeline { fi } - render_nginx_config() { + render_nginx_https_config() { sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf } + render_nginx_development_http_config() { + sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf + } + render_api_env_example() { sed \ -e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \ @@ -130,12 +141,12 @@ pipeline { validate_nginx_tls() { local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then - echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 INSTALL_NGINX_CONFIG=false。" >&2 + echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 NGINX_CONFIG_MODE=none。" >&2 exit 1 fi if [[ ! -f "${cert_dir}/fullchain.pem" || ! -f "${cert_dir}/privkey.pem" ]]; then echo "[server-provision] 未找到 Nginx HTTPS 证书: ${cert_dir}/fullchain.pem 或 ${cert_dir}/privkey.pem" >&2 - echo "[server-provision] 请先完成证书申请,或首次初始化时设置 INSTALL_NGINX_CONFIG=false,避免写入无法通过 nginx -t 的配置。" >&2 + echo "[server-provision] 请先完成证书申请,或首次初始化时设置 NGINX_CONFIG_MODE=none,避免写入无法通过 nginx -t 的配置。" >&2 exit 1 fi } @@ -143,12 +154,22 @@ pipeline { install_nginx_config_with_rollback() { local config_target="/etc/nginx/conf.d/genarrative.conf" local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" + local config_source local rendered_config rendered_snippet config_backup snippet_backup local had_config="false" local had_snippet="false" run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d - echo "+ render deploy/nginx/genarrative.conf -> ${config_target}" + if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then + config_source="deploy/nginx/genarrative.conf" + elif [[ "${NGINX_CONFIG_MODE}" == "development-http" ]]; then + config_source="deploy/nginx/genarrative-dev-http.conf" + else + echo "[server-provision] NGINX_CONFIG_MODE=${NGINX_CONFIG_MODE} 不需要安装 Nginx 配置。" + return + fi + + echo "+ render ${config_source} -> ${config_target}" echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" if [[ "${DRY_RUN}" == "true" ]]; then @@ -156,12 +177,16 @@ pipeline { return fi - validate_nginx_tls rendered_config="$(mktemp)" rendered_snippet="$(mktemp)" config_backup="$(mktemp)" snippet_backup="$(mktemp)" - render_nginx_config >"${rendered_config}" + if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then + validate_nginx_tls + render_nginx_https_config >"${rendered_config}" + else + render_nginx_development_http_config >"${rendered_config}" + fi cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" if [[ -f "${config_target}" ]]; then @@ -244,13 +269,14 @@ pipeline { require_path deploy/systemd/spacetimedb.service require_path deploy/systemd/genarrative-api.service require_path deploy/nginx/genarrative.conf + require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/snippets/genarrative-maintenance.conf require_path deploy/env/api-server.env.example require_path scripts/deploy/maintenance-on.sh require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh - echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, source_commit=$(cat .jenkins-source-commit)" + echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" run_cmd id run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth @@ -299,7 +325,7 @@ pipeline { echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" fi - if [[ "${INSTALL_NGINX_CONFIG}" == "true" ]]; then + if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then install_nginx_config_with_rollback else cleanup_placeholder_nginx_config @@ -325,7 +351,7 @@ pipeline { post { success { - echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}" + echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}, nginxConfigMode=${params.NGINX_CONFIG_MODE}" } } } diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index d0b41e85..ce6f3c19 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -399,7 +399,7 @@ cat >"${TARGET_DIR}/README.md" < Date: Sat, 2 May 2026 21:56:07 +0800 Subject: [PATCH 13/25] Isolate Jenkins cargo environment --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 +++ jenkins/Jenkinsfile.production-api-build | 3 +- .../Jenkinsfile.production-stdb-module-build | 3 +- scripts/jenkins-prepare-cargo-env.sh | 36 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 scripts/jenkins-prepare-cargo-env.sh diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 97bd5ee3..428a2c5f 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -319,6 +319,10 @@ environment { 其中 `` 使用 `api-server` 或 `stdb-module`。API 与 Stdb module 并行构建时不能共享同一个 `CARGO_HOME` 或 `CARGO_TARGET_DIR`,否则容易在 Cargo package cache 或 target 目录上出现 `Blocking waiting for file lock on package cache` 等锁等待。 +Rust 构建流水线还必须在真正执行 `cargo` 前 source `scripts/jenkins-prepare-cargo-env.sh`。该脚本会把 `HOME` 临时切到组件级缓存目录,显式导出组件级 `CARGO_HOME`、`CARGO_TARGET_DIR`、`SCCACHE_DIR`,并在 `${CARGO_HOME}/config.toml` 写入可用的 Cargo sparse registry 配置。这样可以避免构建 agent 使用 `root` 账户时继续读取 `/root/.cargo/config` 中失效的全局镜像配置,例如错误的 `replace-with = "tuna"` 导致 `config.json not found in registry`。 + +`server-rs/.cargo/config.toml` 只保留 Linux release 目标的 linker/rustflags 等仓库级构建配置,不在仓库级 `config.toml` 里重定义 agent 全局镜像源。不要把这些约束写到单个 crate 的 `Cargo.toml`,因为 Cargo 不会从 crate manifest 的 `[target.x86_64-unknown-linux-gnu]` 读取构建器配置。 + `scripts/build-production-release.sh` 必须尊重 `CARGO_TARGET_DIR`,不能硬编码从 `server-rs/target/` 拷贝 Rust 产物。脚本中的产物路径应按以下口径计算: ```bash diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 4e182083..614d6adb 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -62,7 +62,8 @@ pipeline { sh ''' bash -lc ' set -euo pipefail - mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + chmod +x scripts/jenkins-prepare-cargo-env.sh + source scripts/jenkins-prepare-cargo-env.sh if ! command -v sccache >/dev/null 2>&1; then echo "[api-build] 未找到 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 7924f019..70f83796 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -63,7 +63,8 @@ pipeline { sh ''' bash -lc ' set -euo pipefail - mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" + chmod +x scripts/jenkins-prepare-cargo-env.sh + source scripts/jenkins-prepare-cargo-env.sh if ! command -v sccache >/dev/null 2>&1; then echo "[stdb-build] 未找到 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER diff --git a/scripts/jenkins-prepare-cargo-env.sh b/scripts/jenkins-prepare-cargo-env.sh new file mode 100644 index 00000000..dc8127f5 --- /dev/null +++ b/scripts/jenkins-prepare-cargo-env.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${CARGO_HOME:?需要设置 CARGO_HOME}" +: "${CARGO_TARGET_DIR:?需要设置 CARGO_TARGET_DIR}" +: "${SCCACHE_DIR:?需要设置 SCCACHE_DIR}" + +ORIGINAL_HOME="${HOME:-}" +CARGO_BUILD_HOME="${CARGO_BUILD_HOME:-$(dirname "${CARGO_HOME}")/home}" + +mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" "${CARGO_BUILD_HOME}" + +# Jenkins agent 当前以 root 运行时,/root/.cargo/config 可能带有失效镜像配置。 +# 生产构建使用组件级 HOME 与 CARGO_HOME,避免被全局 Cargo 配置污染。 +if [[ -z "${RUSTUP_HOME:-}" && -n "${ORIGINAL_HOME}" && -d "${ORIGINAL_HOME}/.rustup" ]]; then + export RUSTUP_HOME="${ORIGINAL_HOME}/.rustup" +fi + +export HOME="${CARGO_BUILD_HOME}" +export CARGO_HOME +export CARGO_TARGET_DIR +export SCCACHE_DIR + +cat >"${CARGO_HOME}/config.toml" <<'CARGO_CONFIG' +[registries.crates-io] +protocol = "sparse" +CARGO_CONFIG + +echo "[cargo-env] HOME=${HOME}" +echo "[cargo-env] CARGO_HOME=${CARGO_HOME}" +echo "[cargo-env] CARGO_TARGET_DIR=${CARGO_TARGET_DIR}" +echo "[cargo-env] SCCACHE_DIR=${SCCACHE_DIR}" +if [[ -n "${RUSTUP_HOME:-}" ]]; then + echo "[cargo-env] RUSTUP_HOME=${RUSTUP_HOME}" +fi From ace13c704733c0620d2eba8e8854dcb51480b766 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 22:10:01 +0800 Subject: [PATCH 14/25] Install production build toolchain in provision --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 9 +++++--- jenkins/Jenkinsfile.production-api-build | 4 ++++ ...nkinsfile.production-full-build-and-deploy | 2 ++ .../Jenkinsfile.production-server-provision | 22 +++++++++++++++++++ .../Jenkinsfile.production-stdb-module-build | 4 ++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 428a2c5f..a1de6b16 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -323,6 +323,8 @@ Rust 构建流水线还必须在真正执行 `cargo` 前 source `scripts/jenkins `server-rs/.cargo/config.toml` 只保留 Linux release 目标的 linker/rustflags 等仓库级构建配置,不在仓库级 `config.toml` 里重定义 agent 全局镜像源。不要把这些约束写到单个 crate 的 `Cargo.toml`,因为 Cargo 不会从 crate manifest 的 `[target.x86_64-unknown-linux-gnu]` 读取构建器配置。 +由于 `server-rs/.cargo/config.toml` 使用 `clang` 与 `-fuse-ld=lld` 构建 Linux release 目标,构建 agent 必须安装 `clang` 和 `lld`。`Genarrative-Server-Provision` 负责通过 `apt-get`、`dnf` 或 `yum` 安装 `clang`、`lld`、`pkg-config/pkgconf-pkg-config`、OpenSSL headers 与 CA 证书;API/Stdb 构建流水线在执行 Cargo 前必须检查 `clang` 与 `lld`,缺失时直接失败并提示先运行 Server-Provision。 + `scripts/build-production-release.sh` 必须尊重 `CARGO_TARGET_DIR`,不能硬编码从 `server-rs/target/` 拷贝 Rust 产物。脚本中的产物路径应按以下口径计算: ```bash @@ -379,13 +381,13 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 安装或更新 SpacetimeDB。 - 安装 systemd unit。 - 可选安装 Nginx 配置和维护模式 snippet。 -- 安装 Nginx 配置时执行 `nginx -t`。 +- 安装 Nginx 配置时执行 `nginx -t`,通过后必须执行 `nginx -s reload`,确保新配置对当前 Nginx master/worker 生效。 - 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 -首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。 +首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。 若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 @@ -450,6 +452,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 先解析一次最终 `SOURCE_COMMIT`,所有下游构建和发布都使用同一个分支与 commit。 - 并行执行 Web / API / Stdb 三条构建流水线。 +- 并行构建阶段必须开启 fail-fast:任一构建流水线失败时,立即中断其他仍在执行的并行构建分支,本次全量编排不再继续进入发布阶段。 - 构建全部成功后,按顺序执行 Stdb publish、API deploy、Web deploy,并把同一个 `DEPLOY_TARGET` 透传给三条发布流水线。 - 每条下游构建都只消费自己的归档产物,不直接复用别的 workspace。 - 生产 Web 发布只处理 `web.tar.gz` 与 checksum,API 发布只处理 `api-server` 与 checksum,Stdb 发布只处理 `spacetime_module.wasm` 与 checksum。 @@ -497,7 +500,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module `Genarrative-Full-Build-And-Deploy` 编排: 1. 按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析一次最终 `SOURCE_COMMIT`,默认 `origin/master` 最新 commit。 -2. 并行触发 `Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build`,三条构建都必须使用同一个 `SOURCE_BRANCH` 和 `SOURCE_COMMIT`。 +2. 并行触发 `Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build`,三条构建都必须使用同一个 `SOURCE_BRANCH` 和 `SOURCE_COMMIT`;并行阶段开启 fail-fast,任一构建失败就中断其他仍在执行的构建分支。 3. 三条构建全部成功后,按顺序触发 `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy`,同样继续透传同一个 `SOURCE_BRANCH`、`SOURCE_COMMIT` 和 `DEPLOY_TARGET`。 4. 最后执行生产 smoke test。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 614d6adb..51be065e 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -64,6 +64,10 @@ pipeline { set -euo pipefail chmod +x scripts/jenkins-prepare-cargo-env.sh source scripts/jenkins-prepare-cargo-env.sh + if ! command -v clang >/dev/null 2>&1 || ! command -v lld >/dev/null 2>&1; then + echo "[api-build] 缺少 clang/lld。请先运行 Genarrative-Server-Provision 安装 Linux 构建依赖。" >&2 + exit 1 + fi if ! command -v sccache >/dev/null 2>&1; then echo "[api-build] 未找到 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 1b398f37..cb369b4d 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -66,6 +66,8 @@ pipeline { } stage('Build Components') { + // 任一构建分支失败时立即中断其他并行分支,避免失败后继续消耗构建资源。 + failFast true parallel { stage('Web') { steps { diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 47982be7..54690824 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -123,6 +123,21 @@ pipeline { fi } + install_build_dependencies() { + echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers" + if command -v apt-get >/dev/null 2>&1; then + run_cmd apt-get update + run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates + elif command -v dnf >/dev/null 2>&1; then + run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates + elif command -v yum >/dev/null 2>&1; then + run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates + else + echo "[server-provision] 未找到 apt-get/dnf/yum,无法自动安装 clang/lld。请手动安装后重跑构建。" >&2 + exit 1 + fi + } + render_nginx_https_config() { sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf } @@ -174,6 +189,7 @@ pipeline { if [[ "${DRY_RUN}" == "true" ]]; then echo "+ nginx -t" + echo "+ nginx -s reload" return fi @@ -216,6 +232,8 @@ pipeline { rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" exit 1 fi + echo "+ nginx -s reload" + nginx -s reload rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" } @@ -239,6 +257,9 @@ pipeline { if command -v nginx >/dev/null 2>&1; then if ! nginx -t; then echo "[server-provision] 占位配置已禁用,但 nginx -t 仍失败;请检查其他 Nginx 配置。" >&2 + else + echo "+ nginx -s reload" + nginx -s reload fi fi fi @@ -279,6 +300,7 @@ pipeline { echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" run_cmd id + install_build_dependencies run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth if ! id spacetimedb >/dev/null 2>&1; then diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 70f83796..d2c72113 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -65,6 +65,10 @@ pipeline { set -euo pipefail chmod +x scripts/jenkins-prepare-cargo-env.sh source scripts/jenkins-prepare-cargo-env.sh + if ! command -v clang >/dev/null 2>&1 || ! command -v lld >/dev/null 2>&1; then + echo "[stdb-build] 缺少 clang/lld。请先运行 Genarrative-Server-Provision 安装 Linux 构建依赖。" >&2 + exit 1 + fi if ! command -v sccache >/dev/null 2>&1; then echo "[stdb-build] 未找到 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER From 39b114128759ac76f9f844b7b51110819277adb5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 22:35:36 +0800 Subject: [PATCH 15/25] Add production email notification pipeline --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 11 ++- jenkins/Jenkinsfile.production-api-build | 30 ++++++++ jenkins/Jenkinsfile.production-api-deploy | 29 ++++++++ .../Jenkinsfile.production-database-export | 29 ++++++++ .../Jenkinsfile.production-database-import | 29 ++++++++ ...nkinsfile.production-full-build-and-deploy | 35 ++++++++++ jenkins/Jenkinsfile.production-notify-email | 69 +++++++++++++++++++ .../Jenkinsfile.production-server-provision | 29 ++++++++ .../Jenkinsfile.production-stdb-module-build | 30 ++++++++ ...Jenkinsfile.production-stdb-module-publish | 29 ++++++++ jenkins/Jenkinsfile.production-web-build | 30 ++++++++ jenkins/Jenkinsfile.production-web-deploy | 29 ++++++++ 12 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 jenkins/Jenkinsfile.production-notify-email diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index a1de6b16..79b1221e 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -30,6 +30,7 @@ - `jenkins/Jenkinsfile.production-server-provision` - `jenkins/Jenkinsfile.production-database-export` - `jenkins/Jenkinsfile.production-database-import` +- `jenkins/Jenkinsfile.production-notify-email` - `npm run build:production-release` 旧 Jenkins 一体化发布链对应的 Jenkinsfile 已从仓库移除,生产构建和发布入口统一切到 `jenkins/Jenkinsfile.production-*`。`scripts/deploy-rust-remote.sh` 等旧发布包脚本暂保留为历史迁移参考,不再作为生产 Jenkins Job 的入口。 @@ -237,6 +238,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 8. `Genarrative-Database-Export` 9. `Genarrative-Database-Import` 10. `Genarrative-Full-Build-And-Deploy` +11. `Genarrative-Notify-Email` 已落地的生产流水线脚本文件: @@ -250,12 +252,17 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 - `jenkins/Jenkinsfile.production-server-provision` - `jenkins/Jenkinsfile.production-database-export` - `jenkins/Jenkinsfile.production-database-import` +- `jenkins/Jenkinsfile.production-notify-email` `Genarrative-Database-Export`、`Genarrative-Database-Import` 的生产版 Jenkinsfile 已落地;旧的数据库导入导出 Jenkinsfile 已删除,避免继续沿用旧部署目录和旧一体化发布链假设。 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 -发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 + +邮件通知的持久收件人不写入 Git,由 Jenkins 全局环境变量 `GENARRATIVE_NOTIFICATION_EMAILS` 保存,多个邮箱用逗号分隔。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把 `GENARRATIVE_NOTIFICATION_EMAILS` 与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给全局持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 + +`GENARRATIVE_NOTIFICATION_EMAILS` 在 Jenkins controller 的 `Manage Jenkins` -> `System` -> `Global properties` -> `Environment variables` 中配置,例如 `ops@example.com,dev@example.com`。SMTP 服务器在同一页面的 `E-mail Notification` 区域配置。该全局变量属于 Jenkins 持久化配置,不作为仓库文件提交。 所有发布流水线必须提供 `DEPLOY_TARGET` 参数,用于选择逻辑部署目标: @@ -547,6 +554,8 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - [x] 更新旧部署文档,标记旧一体化脚本为废弃或迁移对象。 - [x] `jenkins/Jenkinsfile.production-database-export` - [x] `jenkins/Jenkinsfile.production-database-import` +- [x] `jenkins/Jenkinsfile.production-notify-email` +- [x] 所有生产流水线通过 `NOTIFICATION_EMAILS` 参数在结束时触发 `Genarrative-Notify-Email` ## 参考 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 51be065e..814e88c6 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -23,6 +23,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 API 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') @@ -97,6 +98,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), @@ -107,6 +109,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'API 构建流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "API 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" } diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index d9310fd1..ddaa74d8 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -16,6 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') @@ -112,6 +113,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'API 发布流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "API 发布完成: version=${params.BUILD_VERSION}" } diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index 2a54712b..e7298075 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -16,6 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导出脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '导出脚本来源 commit') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') @@ -191,6 +192,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: '数据库导出流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "数据库导出完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, file=${env.EFFECTIVE_EXPORT_NAME}" } diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 1e7d3c43..f130a2d3 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -16,6 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导入脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '导入脚本来源 commit') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') @@ -308,6 +309,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: '数据库导入流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "数据库导入流水线完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, dryRun=${params.DRY_RUN}" } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index cb369b4d..a2789212 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -20,6 +20,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名') @@ -79,6 +80,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), booleanParam(name: 'RUN_NPM_CI', value: params.RUN_NPM_CI), ] env.WEB_BUILD_NUMBER = webRun.number.toString() @@ -95,6 +97,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), ] env.API_BUILD_NUMBER = apiRun.number.toString() } @@ -110,6 +113,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DATABASE', value: params.DATABASE), ] env.STDB_BUILD_NUMBER = stdbRun.number.toString() @@ -128,6 +132,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DATABASE', value: params.DATABASE), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), @@ -146,6 +151,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: params.API_BUILD_JOB_NAME), @@ -163,6 +169,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: params.WEB_BUILD_JOB_NAME), @@ -173,6 +180,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: '全量构建发布编排结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Full Build-And-Deploy 完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" } diff --git a/jenkins/Jenkinsfile.production-notify-email b/jenkins/Jenkinsfile.production-notify-email new file mode 100644 index 00000000..b1d535de --- /dev/null +++ b/jenkins/Jenkinsfile.production-notify-email @@ -0,0 +1,69 @@ +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '0')) + } + + parameters { + string(name: 'EMAIL_RECIPIENTS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'SOURCE_JOB_NAME', defaultValue: '', description: '来源流水线名称') + string(name: 'SOURCE_BUILD_NUMBER', defaultValue: '', description: '来源构建号') + string(name: 'SOURCE_BUILD_URL', defaultValue: '', description: '来源构建 URL') + string(name: 'SOURCE_RESULT', defaultValue: 'UNKNOWN', description: '来源流水线结果') + string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支') + string(name: 'SOURCE_COMMIT', defaultValue: '', description: '源码 commit') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号') + string(name: 'DEPLOY_TARGET', defaultValue: '', description: '部署目标') + string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB database') + string(name: 'SUMMARY', defaultValue: '', description: '补充摘要') + } + + stages { + stage('Send Email') { + steps { + script { + def recipientList = [] + [env.GENARRATIVE_NOTIFICATION_EMAILS, params.EMAIL_RECIPIENTS].each { rawRecipients -> + rawRecipients?.split(',')?.each { recipient -> + def normalized = recipient.trim() + if (normalized && !recipientList.contains(normalized)) { + recipientList.add(normalized) + } + } + } + def recipients = recipientList.join(',') + if (!recipients) { + echo '[notify-email] EMAIL_RECIPIENTS 与 GENARRATIVE_NOTIFICATION_EMAILS 均未配置,跳过邮件发送。' + return + } + + def result = params.SOURCE_RESULT?.trim() ?: 'UNKNOWN' + def jobName = params.SOURCE_JOB_NAME?.trim() ?: 'unknown-job' + def buildNumber = params.SOURCE_BUILD_NUMBER?.trim() ?: 'unknown-build' + def subject = "[Genarrative][${result}] ${jobName} #${buildNumber}" + def body = """Genarrative Jenkins 流水线执行结果 + +结果: ${result} +流水线: ${jobName} +构建号: ${buildNumber} +构建 URL: ${params.SOURCE_BUILD_URL ?: ''} +源码分支: ${params.SOURCE_BRANCH ?: ''} +源码 commit: ${params.SOURCE_COMMIT ?: ''} +发布版本: ${params.BUILD_VERSION ?: ''} +部署目标: ${params.DEPLOY_TARGET ?: ''} +数据库: ${params.DATABASE ?: ''} +摘要: ${params.SUMMARY ?: ''} +""" + + mail to: recipients, subject: subject, body: body + echo "[notify-email] 已发送邮件: recipients=${recipients}, source=${jobName} #${buildNumber}, result=${result}" + } + } + } + } +} diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 54690824..e2bac04e 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -14,6 +14,7 @@ pipeline { parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') @@ -372,6 +373,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: '服务器初始化流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}, nginxConfigMode=${params.NGINX_CONFIG_MODE}" } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index d2c72113..d8f1e4a7 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -23,6 +23,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') @@ -98,6 +99,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), @@ -109,6 +111,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'Stdb module 构建流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Stdb module 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 4465aea5..5d5721d8 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -16,6 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') @@ -115,6 +116,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'Stdb module 发布流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Stdb module 发布完成: version=${params.BUILD_VERSION}, database=${params.DATABASE}" } diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index b6452fe0..0cf66b92 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -17,6 +17,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否执行 npm ci') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') @@ -87,6 +88,7 @@ pipeline { string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: env.JOB_NAME), @@ -97,6 +99,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'Web 构建流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Web 构建完成: version=${env.EFFECTIVE_BUILD_VERSION}, commit=${env.SOURCE_COMMIT}" } diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 8cb750b5..146ee6cd 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -16,6 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') @@ -110,6 +111,34 @@ pipeline { } post { + always { + script { + def notificationParameters = [ + string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME), + string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER), + string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''), + string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), + string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), + string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), + string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), + string(name: 'DATABASE', value: params.DATABASE ?: ''), + string(name: 'SUMMARY', value: 'Web 发布流水线结束'), + ] + def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() + if (notificationRecipients) { + notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients)) + } + try { + build job: 'Genarrative-Notify-Email', + wait: false, + propagate: false, + parameters: notificationParameters + } catch (error) { + echo "邮件通知触发失败: ${error.message}" + } + } + } success { echo "Web 发布完成: version=${params.BUILD_VERSION}" } From 813dbf1fdd9cd9282c2d2551fc2fe5a87bf05dd0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 22:47:55 +0800 Subject: [PATCH 16/25] Persist notification recipients in Jenkins credentials --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 +- jenkins/Jenkinsfile.production-api-build | 2 +- jenkins/Jenkinsfile.production-api-deploy | 2 +- .../Jenkinsfile.production-database-export | 2 +- .../Jenkinsfile.production-database-import | 2 +- ...nkinsfile.production-full-build-and-deploy | 2 +- jenkins/Jenkinsfile.production-notify-email | 52 ++++++++++++------- .../Jenkinsfile.production-server-provision | 2 +- .../Jenkinsfile.production-stdb-module-build | 2 +- ...Jenkinsfile.production-stdb-module-publish | 2 +- jenkins/Jenkinsfile.production-web-build | 2 +- jenkins/Jenkinsfile.production-web-deploy | 2 +- 12 files changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 79b1221e..5db4d93b 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -260,9 +260,9 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 -邮件通知的持久收件人不写入 Git,由 Jenkins 全局环境变量 `GENARRATIVE_NOTIFICATION_EMAILS` 保存,多个邮箱用逗号分隔。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把 `GENARRATIVE_NOTIFICATION_EMAILS` 与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给全局持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 +邮件通知的持久收件人不写入 Git,由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 -`GENARRATIVE_NOTIFICATION_EMAILS` 在 Jenkins controller 的 `Manage Jenkins` -> `System` -> `Global properties` -> `Environment variables` 中配置,例如 `ops@example.com,dev@example.com`。SMTP 服务器在同一页面的 `E-mail Notification` 区域配置。该全局变量属于 Jenkins 持久化配置,不作为仓库文件提交。 +持久收件人在 Jenkins controller 的 `Manage Jenkins` -> `Credentials` -> `System` -> `Global credentials` 中新增 `Secret text` 凭据,`ID` 固定为 `genarrative-notification-emails`,`Secret` 填 `ops@example.com,dev@example.com` 这类逗号分隔邮箱。SMTP 服务器在 `Manage Jenkins` -> `System` 的 `E-mail Notification` 区域配置。邮件地址属于 Jenkins 持久化配置,不作为仓库文件提交。 所有发布流水线必须提供 `DEPLOY_TARGET` 参数,用于选择逻辑部署目标: diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 814e88c6..a76f354a 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -23,7 +23,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 API 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index ddaa74d8..7d227b6d 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -16,7 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index e7298075..cda064f7 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -16,7 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导出脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '导出脚本来源 commit') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index f130a2d3..d866f5ec 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -16,7 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导入脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '导入脚本来源 commit') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index a2789212..a8139dc4 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -20,7 +20,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名') diff --git a/jenkins/Jenkinsfile.production-notify-email b/jenkins/Jenkinsfile.production-notify-email index b1d535de..9a6ee36c 100644 --- a/jenkins/Jenkinsfile.production-notify-email +++ b/jenkins/Jenkinsfile.production-notify-email @@ -10,7 +10,8 @@ pipeline { } parameters { - string(name: 'EMAIL_RECIPIENTS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'EMAIL_RECIPIENTS_CREDENTIAL_ID', defaultValue: 'genarrative-notification-emails', description: '持久收件人 Secret Text 凭据 ID,凭据内容为逗号分隔邮箱;留空则只使用本次追加收件人') + string(name: 'EMAIL_RECIPIENTS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与持久收件人凭据合并发送') string(name: 'SOURCE_JOB_NAME', defaultValue: '', description: '来源流水线名称') string(name: 'SOURCE_BUILD_NUMBER', defaultValue: '', description: '来源构建号') string(name: 'SOURCE_BUILD_URL', defaultValue: '', description: '来源构建 URL') @@ -27,26 +28,27 @@ pipeline { stage('Send Email') { steps { script { - def recipientList = [] - [env.GENARRATIVE_NOTIFICATION_EMAILS, params.EMAIL_RECIPIENTS].each { rawRecipients -> - rawRecipients?.split(',')?.each { recipient -> - def normalized = recipient.trim() - if (normalized && !recipientList.contains(normalized)) { - recipientList.add(normalized) + def sendNotification = { persistedRecipients -> + def recipientList = [] + [persistedRecipients, params.EMAIL_RECIPIENTS].each { rawRecipients -> + rawRecipients?.split(',')?.each { recipient -> + def normalized = recipient.trim() + if (normalized && !recipientList.contains(normalized)) { + recipientList.add(normalized) + } } } - } - def recipients = recipientList.join(',') - if (!recipients) { - echo '[notify-email] EMAIL_RECIPIENTS 与 GENARRATIVE_NOTIFICATION_EMAILS 均未配置,跳过邮件发送。' - return - } + def recipients = recipientList.join(',') + if (!recipients) { + echo '[notify-email] 持久收件人凭据与 EMAIL_RECIPIENTS 均未配置,跳过邮件发送。' + return + } - def result = params.SOURCE_RESULT?.trim() ?: 'UNKNOWN' - def jobName = params.SOURCE_JOB_NAME?.trim() ?: 'unknown-job' - def buildNumber = params.SOURCE_BUILD_NUMBER?.trim() ?: 'unknown-build' - def subject = "[Genarrative][${result}] ${jobName} #${buildNumber}" - def body = """Genarrative Jenkins 流水线执行结果 + def result = params.SOURCE_RESULT?.trim() ?: 'UNKNOWN' + def jobName = params.SOURCE_JOB_NAME?.trim() ?: 'unknown-job' + def buildNumber = params.SOURCE_BUILD_NUMBER?.trim() ?: 'unknown-build' + def subject = "[Genarrative][${result}] ${jobName} #${buildNumber}" + def body = """Genarrative Jenkins 流水线执行结果 结果: ${result} 流水线: ${jobName} @@ -60,8 +62,18 @@ pipeline { 摘要: ${params.SUMMARY ?: ''} """ - mail to: recipients, subject: subject, body: body - echo "[notify-email] 已发送邮件: recipients=${recipients}, source=${jobName} #${buildNumber}, result=${result}" + mail to: recipients, subject: subject, body: body + echo "[notify-email] 已发送邮件: recipients=${recipients}, source=${jobName} #${buildNumber}, result=${result}" + } + + def credentialId = params.EMAIL_RECIPIENTS_CREDENTIAL_ID?.trim() + if (credentialId) { + withCredentials([string(credentialsId: credentialId, variable: 'PERSISTED_EMAIL_RECIPIENTS')]) { + sendNotification(env.PERSISTED_EMAIL_RECIPIENTS) + } + } else { + sendNotification('') + } } } } diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index e2bac04e..e6bfc81a 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -14,7 +14,7 @@ pipeline { parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index d8f1e4a7..17aec0c2 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -23,7 +23,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 5d5721d8..da51c059 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -16,7 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 0cf66b92..ff5aa5c1 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -17,7 +17,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否执行 npm ci') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 146ee6cd..c2d9db86 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -16,7 +16,7 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit;上游触发时传实际构建 commit') - string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins 全局环境变量 GENARRATIVE_NOTIFICATION_EMAILS 合并发送') + string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') From 36c81c30be2e7d4919caad8f2d2d4f793ce53b0e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 23:11:27 +0800 Subject: [PATCH 17/25] Avoid logging notification recipient secrets --- jenkins/Jenkinsfile.production-notify-email | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/Jenkinsfile.production-notify-email b/jenkins/Jenkinsfile.production-notify-email index 9a6ee36c..38936efe 100644 --- a/jenkins/Jenkinsfile.production-notify-email +++ b/jenkins/Jenkinsfile.production-notify-email @@ -63,7 +63,7 @@ pipeline { """ mail to: recipients, subject: subject, body: body - echo "[notify-email] 已发送邮件: recipients=${recipients}, source=${jobName} #${buildNumber}, result=${result}" + echo "[notify-email] 已发送邮件: recipientCount=${recipientList.size()}, source=${jobName} #${buildNumber}, result=${result}" } def credentialId = params.EMAIL_RECIPIENTS_CREDENTIAL_ID?.trim() From 98c83478ad46d25ef5ab21aa69024705d27aef35 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 23:14:57 +0800 Subject: [PATCH 18/25] Run notification pipeline without build agent --- jenkins/Jenkinsfile.production-notify-email | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jenkins/Jenkinsfile.production-notify-email b/jenkins/Jenkinsfile.production-notify-email index 38936efe..61f5a969 100644 --- a/jenkins/Jenkinsfile.production-notify-email +++ b/jenkins/Jenkinsfile.production-notify-email @@ -1,7 +1,5 @@ pipeline { - agent { - label 'linux && genarrative-build' - } + agent none options { disableConcurrentBuilds() From 4358f3825993fcafe4adcb9aacf3bd7f1880042e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 2 May 2026 23:56:28 +0800 Subject: [PATCH 19/25] Install sccache during server provision --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 +-- .../Jenkinsfile.production-server-provision | 36 +++++++++++++++++++ scripts/jenkins-prepare-cargo-env.sh | 8 +++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 5db4d93b..baf79d06 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -330,7 +330,7 @@ Rust 构建流水线还必须在真正执行 `cargo` 前 source `scripts/jenkins `server-rs/.cargo/config.toml` 只保留 Linux release 目标的 linker/rustflags 等仓库级构建配置,不在仓库级 `config.toml` 里重定义 agent 全局镜像源。不要把这些约束写到单个 crate 的 `Cargo.toml`,因为 Cargo 不会从 crate manifest 的 `[target.x86_64-unknown-linux-gnu]` 读取构建器配置。 -由于 `server-rs/.cargo/config.toml` 使用 `clang` 与 `-fuse-ld=lld` 构建 Linux release 目标,构建 agent 必须安装 `clang` 和 `lld`。`Genarrative-Server-Provision` 负责通过 `apt-get`、`dnf` 或 `yum` 安装 `clang`、`lld`、`pkg-config/pkgconf-pkg-config`、OpenSSL headers 与 CA 证书;API/Stdb 构建流水线在执行 Cargo 前必须检查 `clang` 与 `lld`,缺失时直接失败并提示先运行 Server-Provision。 +由于 `server-rs/.cargo/config.toml` 使用 `clang` 与 `-fuse-ld=lld` 构建 Linux release 目标,构建 agent 必须安装 `clang` 和 `lld`。`Genarrative-Server-Provision` 负责通过 `apt-get`、`dnf` 或 `yum` 安装 `clang`、`lld`、`pkg-config/pkgconf-pkg-config`、OpenSSL headers 与 CA 证书;同时负责在缺失时通过 `cargo install sccache --locked` 补齐 `sccache`,让 API/Stdb 构建流水线的 `RUSTC_WRAPPER=sccache` 真正生效。API/Stdb 构建流水线在执行 Cargo 前必须检查 `clang` 与 `lld`,缺失时直接失败并提示先运行 Server-Provision。 `scripts/build-production-release.sh` 必须尊重 `CARGO_TARGET_DIR`,不能硬编码从 `server-rs/target/` 拷贝 Rust 产物。脚本中的产物路径应按以下口径计算: @@ -343,7 +343,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 并发与清理规则: - 同一个 Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,避免同一组件的多个 release 构建同时写入同一最终产物路径。 -- 如果 Linux agent 未安装 `sccache`,Rust 构建流水线必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。 +- 如果 Linux agent 未安装 `sccache`,应先运行 `Genarrative-Server-Provision` 补齐缓存工具;Rust 构建流水线仍必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。 - 生产发布流水线只能消费 `build//` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 - `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 - 对 `/var/cache/genarrative-build/*/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index e6bfc81a..1ea7c214 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -139,6 +139,41 @@ pipeline { fi } + install_sccache() { + for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do + if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then + export PATH="${tool_dir}:${PATH}" + fi + done + + if command -v sccache >/dev/null 2>&1; then + echo "[server-provision] sccache 已存在: $(command -v sccache)" + return + fi + + if [[ -x /root/.cargo/bin/sccache ]]; then + echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache" + return + fi + + echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。" + if ! command -v cargo >/dev/null 2>&1; then + echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2 + exit 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ cargo install sccache --locked" + return + fi + + cargo install sccache --locked + if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then + echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2 + exit 1 + fi + } + render_nginx_https_config() { sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf } @@ -302,6 +337,7 @@ pipeline { run_cmd id install_build_dependencies + install_sccache run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth if ! id spacetimedb >/dev/null 2>&1; then diff --git a/scripts/jenkins-prepare-cargo-env.sh b/scripts/jenkins-prepare-cargo-env.sh index dc8127f5..c9d51991 100644 --- a/scripts/jenkins-prepare-cargo-env.sh +++ b/scripts/jenkins-prepare-cargo-env.sh @@ -17,6 +17,14 @@ if [[ -z "${RUSTUP_HOME:-}" && -n "${ORIGINAL_HOME}" && -d "${ORIGINAL_HOME}/.ru export RUSTUP_HOME="${ORIGINAL_HOME}/.rustup" fi +# HOME 会在下面切到组件级缓存目录,因此这里先把真实用户的 Rust 工具链目录补进 PATH。 +# Server-Provision 通过 cargo install 安装的 sccache 通常会落在 /root/.cargo/bin。 +for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do + if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then + export PATH="${tool_dir}:${PATH}" + fi +done + export HOME="${CARGO_BUILD_HOME}" export CARGO_HOME export CARGO_TARGET_DIR From 019c8a2b03d21601cca20116d8f347020172200b Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 00:10:43 +0800 Subject: [PATCH 20/25] Keep web artifacts on build agent --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 8 +++--- jenkins/Jenkinsfile.production-web-build | 25 ++++++++++++++++++- jenkins/Jenkinsfile.production-web-deploy | 25 ++++++++++++++++++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index baf79d06..aeb13536 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -177,7 +177,7 @@ build// └─ README.md ``` -`web/` 可以保留在构建目录中供本地 smoke test 与人工排查使用,但 Jenkins Web Build 归档和 Web Deploy 传输必须以 `web.tar.gz` 为主,避免把大量静态碎文件逐个传回 Jenkins controller。`api-server` 和 `spacetime_module.wasm` 是单文件产物,默认直接归档单文件与对应 `.sha256`,不强制压缩。 +`web/` 可以保留在构建目录中供本地 smoke test 与人工排查使用。Web Build 必须生成 `web.tar.gz` 与 `web.tar.gz.sha256`,但 `web.tar.gz` 不作为 Jenkins controller 默认归档对象,避免每次把约数百 MB 的 Web 大包从 Linux agent 拉回本地 controller。Web 大包保存在构建机稳定目录 `/var/cache/genarrative-build/web-artifacts////`,Jenkins 只归档 `web.tar.gz.sha256`、`release-manifest.json` 与 `web-artifact-pointer.txt`。`api-server` 和 `spacetime_module.wasm` 是单文件产物,默认直接归档单文件与对应 `.sha256`,不强制压缩。 不再生成旧产物: @@ -258,7 +258,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 -发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller,`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/`,`Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机;release 目标若不是同一台机器,必须先把该目录通过共享存储、rsync 或其它内网同步方式提供给 release 部署 agent。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 邮件通知的持久收件人不写入 Git,由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 @@ -408,12 +408,12 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 构建后台前端,base path 为 `/admin/`。 - 生成或复制 `maintenance.html`。 - 将 `web/` 打包为 `web.tar.gz`,生成 `web.tar.gz.sha256`。 -- 归档 `web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json`;`web/` 展开目录不作为 Jenkins 主归档对象。 +- 将 `web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json` 复制到 `/var/cache/genarrative-build/web-artifacts////`;Jenkins 只归档 `web.tar.gz.sha256`、`release-manifest.json` 和 `web-artifact-pointer.txt`,不把 `web.tar.gz` 拉回 controller。 发布: - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 -- 获取 `web.tar.gz` 与 `web.tar.gz.sha256`,先校验 checksum,再解压到 `/opt/genarrative/releases//web`。 +- 通过 Jenkins 归档获取 `web.tar.gz.sha256`、`release-manifest.json` 和 `web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts////` 读取 `web.tar.gz`;先校验 checksum,再解压到 `/opt/genarrative/releases//web`。 - 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 - 执行 Nginx 配置测试和静态页面 smoke test。 - 不进入维护模式。 diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index ff5aa5c1..e42d29c4 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -11,6 +11,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts' } parameters { @@ -72,7 +73,29 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz,build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json", fingerprint: true + sh ''' + bash -lc ' + set -euo pipefail + + artifact_dir="${WEB_ARTIFACT_ROOT}/${JOB_NAME}/${BUILD_NUMBER}/${EFFECTIVE_BUILD_VERSION}" + mkdir -p "${artifact_dir}" + rm -f "${artifact_dir}/web.tar.gz" "${artifact_dir}/web.tar.gz.sha256" "${artifact_dir}/release-manifest.json" + install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/web.tar.gz" "${artifact_dir}/web.tar.gz" + install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256" "${artifact_dir}/web.tar.gz.sha256" + install -m 0644 "build/${EFFECTIVE_BUILD_VERSION}/release-manifest.json" "${artifact_dir}/release-manifest.json" + + cat >"build/${EFFECTIVE_BUILD_VERSION}/web-artifact-pointer.txt" <&2 + echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机;release 目标需要预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2 + exit 1 + fi + + mkdir -p "build/${BUILD_VERSION}" + cp -f "${artifact_dir}/web.tar.gz" "build/${BUILD_VERSION}/web.tar.gz" + if [[ -f "${artifact_dir}/web.tar.gz.sha256" ]]; then + cp -f "${artifact_dir}/web.tar.gz.sha256" "build/${BUILD_VERSION}/web.tar.gz.sha256" + fi + if [[ -f "${artifact_dir}/release-manifest.json" ]]; then + cp -f "${artifact_dir}/release-manifest.json" "build/${BUILD_VERSION}/release-manifest.json" + fi + echo "[web-deploy] 已从构建机本地目录获取 Web 大包: ${artifact_dir}" + ' + ''' } } From 96f13bdfed5911b44262d05275e578eee3424fcc Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 00:49:36 +0800 Subject: [PATCH 21/25] fix: avoid production spacetimedb port conflict --- deploy/env/api-server.env.example | 2 +- deploy/nginx/genarrative-dev-http.conf | 4 ++-- deploy/nginx/genarrative.conf | 4 ++-- deploy/systemd/spacetimedb.service | 2 +- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 7 +++--- .../Jenkinsfile.production-database-export | 2 +- .../Jenkinsfile.production-database-import | 2 +- ...nkinsfile.production-full-build-and-deploy | 2 ++ .../Jenkinsfile.production-server-provision | 2 +- ...Jenkinsfile.production-stdb-module-publish | 17 +++++++++++++- scripts/deploy/production-stdb-publish.sh | 22 ++++++++++++++++--- 11 files changed, 50 insertions(+), 16 deletions(-) diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 25016c80..bd69bd54 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -22,7 +22,7 @@ AUTH_REFRESH_COOKIE_SECURE=true GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false -GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3000 +GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101 GENARRATIVE_SPACETIME_DATABASE=genarrative-prod GENARRATIVE_SPACETIME_TOKEN= GENARRATIVE_SPACETIME_POOL_SIZE=8 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index dd7e27ed..340ebcba 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -55,7 +55,7 @@ server { # 仅开放前端 SpacetimeDB SDK 运行所需的最小公网路由。 location ~ ^/v1/database/[^/]+/subscribe$ { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3101; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; @@ -64,7 +64,7 @@ server { } location ^~ /v1/identity { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3101; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index ca2b388e..828339e5 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -69,7 +69,7 @@ server { # SpacetimeDB 只开放 TypeScript SDK 运行所需的最小公网路由。 location ~ ^/v1/database/[^/]+/subscribe$ { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3101; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; @@ -78,7 +78,7 @@ server { } location ^~ /v1/identity { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3101; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; diff --git a/deploy/systemd/spacetimedb.service b/deploy/systemd/spacetimedb.service index 16b236f9..93fdbede 100644 --- a/deploy/systemd/spacetimedb.service +++ b/deploy/systemd/spacetimedb.service @@ -8,7 +8,7 @@ Type=simple User=spacetimedb Group=spacetimedb WorkingDirectory=/stdb -ExecStart=/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000 +ExecStart=/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3101 Restart=always RestartSec=5 LimitNOFILE=1048576 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index aeb13536..c468db7d 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -44,7 +44,7 @@ ## 生产架构 - Nginx 作为唯一公网入口,负责 HTTPS、静态站点、后台静态页面、维护页与 `/admin/api/` 反向代理。 -- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3000`,数据根目录为 `/stdb`。 +- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3101`,数据根目录为 `/stdb`。`3000` 保留给部署机本机 Git/Web 服务,禁止再让 SpacetimeDB 占用该端口。 - Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。 - 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。 - 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。 @@ -77,7 +77,7 @@ - 服务名:`spacetimedb.service` - 运行用户:`spacetimedb` - 工作目录:`/stdb` -- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000` +- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3101` - 对外暴露:默认不直接暴露公网端口。 该方案与 SpacetimeDB 官方自托管文档一致:使用 Ubuntu、专用用户、`/stdb` 根目录、systemd 服务和 Nginx。 @@ -210,6 +210,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 - Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。 - Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 +- 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`。后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 @@ -474,7 +475,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 通过 `DEPLOY_TARGET` 选择逻辑导出目标;`development` 映射到 `linux && genarrative-build`,`release` 映射到 `linux && genarrative-release-deploy`。 - `release` 导出必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,避免当前开发/构建/开发部署 agent 冒充 release 部署机。 - 进入维护模式,避免导出期间继续写入。 -- 从目标机器本机 SpacetimeDB 导出指定数据库数据,默认连接 `SPACETIME_SERVER=local`,自托管 `root-dir` 默认 `/stdb`。 +- 从目标机器本机 SpacetimeDB 导出指定数据库数据,默认连接 `SPACETIME_SERVER_URL=http://127.0.0.1:3101`,自托管 `root-dir` 默认 `/stdb`。 - 产物归档到 Jenkins,并可额外保存到 `SERVER_BACKUP_DIRECTORY`。 - 敏感 token 与 bootstrap secret 只通过 Jenkins Secret Text 凭据 ID 注入,不作为明文 Job 参数。 - 成功后解除维护模式。 diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index cda064f7..3abdef58 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -19,7 +19,7 @@ pipeline { string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') - string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') + string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;release 自托管默认 /stdb') string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') string(name: 'WORKSPACE_EXPORT_DIRECTORY', defaultValue: 'database-exports', description: 'Jenkins workspace 内的导出目录,用于归档') diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index d866f5ec..6c7e8d9d 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -19,7 +19,7 @@ pipeline { string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') - string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') + string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;release 自托管默认 /stdb') choice(name: 'INPUT_SOURCE', choices: ['pipeline_archive', 'manual_upload'], description: '导入数据源;pipeline_archive 从导出流水线归档获取,manual_upload 使用本次构建手动上传文件') string(name: 'INPUT_FILE', defaultValue: '', description: 'pipeline_archive 模式可选;留空时使用导出流水线默认归档路径 database-exports/spacetime-migration-<导出构建号>.json') diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index a8139dc4..1a6d7ccd 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -30,6 +30,7 @@ pipeline { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') + string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'Stdb 发布目标 URL;默认避开本机 Git/Web 使用的 3000 端口') } stages { @@ -134,6 +135,7 @@ pipeline { string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DATABASE', value: params.DATABASE), + string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: params.STDB_BUILD_JOB_NAME), diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 1ea7c214..94d7cfb7 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -185,7 +185,7 @@ pipeline { render_api_env_example() { sed \ -e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \ - -e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3000|" \ + -e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101|" \ deploy/env/api-server.env.example } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index da51c059..bd00bb5f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -22,6 +22,7 @@ pipeline { string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') + string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布') } @@ -47,6 +48,17 @@ pipeline { if (!params.DATABASE?.trim()) { error('DATABASE 不能为空。') } + if (!params.SPACETIME_SERVER?.trim() && !params.SPACETIME_SERVER_URL?.trim()) { + error('SPACETIME_SERVER 与 SPACETIME_SERVER_URL 不能同时为空。') + } + def spacetimeServerUrl = params.SPACETIME_SERVER_URL?.trim() + if (spacetimeServerUrl && !(spacetimeServerUrl ==~ /^https?:\/\/[A-Za-z0-9._:-]+$/)) { + error("SPACETIME_SERVER_URL 只能是 http(s) URL,且不能包含路径或 shell 特殊字符: ${spacetimeServerUrl}") + } + def spacetimeServer = params.SPACETIME_SERVER?.trim() + if (!spacetimeServerUrl && spacetimeServer && !(spacetimeServer ==~ /^[A-Za-z0-9._:-]+$/)) { + error("SPACETIME_SERVER 只能包含字母、数字、点、下划线、冒号和短横线: ${spacetimeServer}") + } } } } @@ -99,6 +111,9 @@ pipeline { steps { script { def clearArg = params.CLEAR_DATABASE ? '--clear-database' : '' + def serverArg = params.SPACETIME_SERVER_URL?.trim() + ? "--server-url \"${params.SPACETIME_SERVER_URL.trim()}\"" + : "--server \"${params.SPACETIME_SERVER}\"" sh """ bash -lc ' set -euo pipefail @@ -106,7 +121,7 @@ pipeline { scripts/deploy/production-stdb-publish.sh \\ --source-dir "build/${params.BUILD_VERSION}" \\ --database "${params.DATABASE}" \\ - --server "${params.SPACETIME_SERVER}" \\ + ${serverArg} \\ ${clearArg} ' """ diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 0929fa96..8ecb797e 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -5,10 +5,11 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server local] [--clear-database] + ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server-url http://127.0.0.1:3101] [--server local] [--clear-database] 说明: 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 + 默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。 失败时保留维护模式。 EOF } @@ -36,6 +37,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="" DATABASE="" SERVER_ALIAS="local" +SERVER_URL="http://127.0.0.1:3101" CLEAR_DATABASE=0 DEPLOY_COMPLETED=0 @@ -55,6 +57,11 @@ while [[ $# -gt 0 ]]; do ;; --server) SERVER_ALIAS="${2:?缺少 --server 的值}" + SERVER_URL="" + shift 2 + ;; + --server-url) + SERVER_URL="${2:?缺少 --server-url 的值}" shift 2 ;; --clear-database) @@ -106,16 +113,25 @@ echo "[production-stdb-publish] 校验 wasm" PUBLISH_ARGS=( publish "${DATABASE}" - --server "${SERVER_ALIAS}" --bin-path "${SOURCE_DIR}/spacetime_module.wasm" --yes ) +if [[ -n "${SERVER_URL}" ]]; then + PUBLISH_ARGS+=(--server "${SERVER_URL}") +else + PUBLISH_ARGS+=(--server "${SERVER_ALIAS}") +fi + if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then PUBLISH_ARGS+=(--clear-database) fi -echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}" +if [[ -n "${SERVER_URL}" ]]; then + echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}" +else + echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}" +fi spacetime "${PUBLISH_ARGS[@]}" "${SCRIPT_DIR}/maintenance-off.sh" From e0d0531c9c6aabae80aa1ddc23093d279f3b6877 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 01:56:24 +0800 Subject: [PATCH 22/25] fix: sync spacetimedb binaries during provision --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 3 +- .../Jenkinsfile.production-server-provision | 114 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index c468db7d..4a00fab8 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -387,10 +387,11 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 创建 `spacetimedb`、`genarrative` 等系统用户。 - 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 - 安装或更新 SpacetimeDB。 +- 安装 SpacetimeDB 时不能只复制 `/usr/local/bin/spacetime` wrapper,还必须把 `spacetimedb-cli` 与 `spacetimedb-standalone` 同步到 `/bin/current/`。否则 `spacetime --root-dir= start` 会回调缺失的 `/bin/current/spacetimedb-cli`,导致 `spacetimedb.service` 循环重启但 provision 表面已经执行过 `systemctl restart`。 - 安装 systemd unit。 - 可选安装 Nginx 配置和维护模式 snippet。 - 安装 Nginx 配置时执行 `nginx -t`,通过后必须执行 `nginx -s reload`,确保新配置对当前 Nginx master/worker 生效。 -- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 +- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`;重启 `spacetimedb.service` 后必须等待 `http://127.0.0.1:3101/v1/ping` 或 `spacetime server ping` 确认就绪,不能只依赖 `systemctl restart` 的返回码。 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 94d7cfb7..3bfefc25 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -174,6 +174,118 @@ pipeline { fi } + sync_spacetime_install() { + local root_dir="$1" + local target_bin_dir="${root_dir}/bin/current" + local target_cli="${target_bin_dir}/spacetimedb-cli" + local target_standalone="${target_bin_dir}/spacetimedb-standalone" + local resolved_command="${SPACETIME_BIN_SOURCE}" + local install_dir="" + local root_bin="${root_dir}/bin" + local share_bin_dir="" + local version_dir="" + local parent_dir="" + + if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then + echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}" + return + fi + + echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ mkdir -p ${target_bin_dir}" + echo "+ copy spacetimedb-cli and spacetimedb-standalone into ${target_bin_dir}" + return + fi + + if command -v readlink >/dev/null 2>&1; then + resolved_command="$(readlink -f "${SPACETIME_BIN_SOURCE}" 2>/dev/null || echo "${SPACETIME_BIN_SOURCE}")" + fi + install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" + mkdir -p "${root_bin}" + + for share_bin_dir in \ + "/usr/.local/share/spacetime/bin" \ + "/root/.local/share/spacetime/bin" \ + "${HOME:-}/.local/share/spacetime/bin"; do + if [[ -d "${share_bin_dir}" ]]; then + version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}" + rm -rf "${target_bin_dir}" + mkdir -p "${target_bin_dir}" + cp -a "${version_dir}/." "${target_bin_dir}/" + chmod +x "${target_cli}" "${target_standalone}" + chown -R spacetimedb:spacetimedb "${root_bin}" + return + fi + fi + done + + if [[ -d "${install_dir}/bin" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" + cp -a "${install_dir}/bin/." "${root_bin}/" + elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" + rm -rf "${target_bin_dir}" + mkdir -p "${target_bin_dir}" + cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" + cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" + chmod +x "${target_cli}" "${target_standalone}" + elif [[ -f "${resolved_command}" ]]; then + parent_dir="$(cd -- "${install_dir}/.." && pwd)" + if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" + cp -a "${parent_dir}/bin/." "${root_bin}/" + else + echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 + fi + fi + + if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装后仍缺少 current 目录。" >&2 + echo "[server-provision] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2 + exit 1 + fi + + chown -R spacetimedb:spacetimedb "${root_bin}" + } + + is_spacetimedb_ready() { + local server_url="http://127.0.0.1:3101" + local output="" + + if command -v curl >/dev/null 2>&1 && curl -fsS "${server_url}/v1/ping" >/dev/null 2>&1; then + return 0 + fi + + output="$("${SPACETIME_ROOT}/spacetime" --root-dir="${SPACETIME_ROOT}" server ping "${server_url}" 2>&1 || true)" + [[ "${output}" == *"Server is online:"* ]] + } + + wait_for_spacetimedb_service() { + local deadline=$((SECONDS + 60)) + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ wait for spacetimedb.service on http://127.0.0.1:3101" + return + fi + + while ((SECONDS < deadline)); do + if is_spacetimedb_ready; then + echo "[server-provision] spacetimedb.service 已就绪: http://127.0.0.1:3101" + return + fi + sleep 1 + done + + echo "[server-provision] 等待 spacetimedb.service 就绪超时。" >&2 + systemctl status spacetimedb.service --no-pager -l >&2 || true + journalctl -u spacetimedb.service --no-pager -n 80 >&2 || true + ss -ltnp >&2 || true + exit 1 + } + render_nginx_https_config() { sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf } @@ -364,6 +476,7 @@ pipeline { install -m 0755 "${SPACETIME_BIN_SOURCE}" "${SPACETIME_ROOT}/spacetime" chown spacetimedb:spacetimedb "${SPACETIME_ROOT}/spacetime" fi + sync_spacetime_install "${SPACETIME_ROOT}" spacetimedb_service="$(mktemp)" api_service="$(mktemp)" @@ -394,6 +507,7 @@ pipeline { if [[ "${ENABLE_SERVICES}" == "true" ]]; then run_cmd systemctl enable spacetimedb.service genarrative-api.service run_cmd systemctl restart spacetimedb.service + wait_for_spacetimedb_service if [[ -x "${CURRENT_LINK}/api-server" ]]; then run_cmd systemctl restart genarrative-api.service else From 562b5eb7200bf6de5a68a58911df9d9abf65c165 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 02:01:16 +0800 Subject: [PATCH 23/25] fix: avoid root spacetime health checks --- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 2 +- jenkins/Jenkinsfile.production-server-provision | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 4a00fab8..fc1f92d1 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -391,7 +391,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 安装 systemd unit。 - 可选安装 Nginx 配置和维护模式 snippet。 - 安装 Nginx 配置时执行 `nginx -t`,通过后必须执行 `nginx -s reload`,确保新配置对当前 Nginx master/worker 生效。 -- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`;重启 `spacetimedb.service` 后必须等待 `http://127.0.0.1:3101/v1/ping` 或 `spacetime server ping` 确认就绪,不能只依赖 `systemctl restart` 的返回码。 +- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`;重启 `spacetimedb.service` 后必须等待 `http://127.0.0.1:3101/v1/ping` 确认就绪,不能只依赖 `systemctl restart` 的返回码。`` 下所有运行态文件必须归属 `spacetimedb:spacetimedb`。不要在 root 身份下对同一个 `` 执行 `spacetime --root-dir= server ping`,否则会生成 root-owned CLI 配置,导致 `spacetimedb` 服务用户后续启动时遇到权限错误。 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 3bfefc25..3c604947 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -253,14 +253,12 @@ pipeline { is_spacetimedb_ready() { local server_url="http://127.0.0.1:3101" - local output="" if command -v curl >/dev/null 2>&1 && curl -fsS "${server_url}/v1/ping" >/dev/null 2>&1; then return 0 fi - output="$("${SPACETIME_ROOT}/spacetime" --root-dir="${SPACETIME_ROOT}" server ping "${server_url}" 2>&1 || true)" - [[ "${output}" == *"Server is online:"* ]] + return 1 } wait_for_spacetimedb_service() { From 62afaf620ac1234ca71dab648f7f9af08c3a9a69 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 02:11:44 +0800 Subject: [PATCH 24/25] fix: publish stdb with service identity --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 +- ...nkinsfile.production-full-build-and-deploy | 4 ++ ...Jenkinsfile.production-stdb-module-publish | 16 +++++ scripts/deploy/production-stdb-publish.sh | 61 +++++++++++++++++-- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index fc1f92d1..6a317d95 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -452,7 +452,8 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 发布脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 - 进入维护模式。 - 将 wasm 上传到生产实例。 -- 在生产实例本机执行 `spacetime publish -s local --bin-path spacetime_module.wasm `。 +- 在生产实例本机执行 `spacetime --root-dir=/stdb publish --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes`。 +- 发布动作默认以 `spacetimedb` 服务用户执行,避免 root 默认 CLI 身份对自托管数据库验签失败,也避免 root 写入 `/stdb/config` 造成后续服务用户启动权限错误。 - 成功后执行必要 smoke test。 - 成功后解除维护模式。 - 失败时保留维护模式并发邮件。 @@ -463,6 +464,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 并行执行 Web / API / Stdb 三条构建流水线。 - 并行构建阶段必须开启 fail-fast:任一构建流水线失败时,立即中断其他仍在执行的并行构建分支,本次全量编排不再继续进入发布阶段。 - 构建全部成功后,按顺序执行 Stdb publish、API deploy、Web deploy,并把同一个 `DEPLOY_TARGET` 透传给三条发布流水线。 +- Stdb publish 同时透传 `SPACETIME_SERVER_URL`、`SPACETIME_ROOT_DIR` 与 `SPACETIME_RUN_AS_USER`,默认分别为 `http://127.0.0.1:3101`、`/stdb`、`spacetimedb`。 - 每条下游构建都只消费自己的归档产物,不直接复用别的 workspace。 - 生产 Web 发布只处理 `web.tar.gz` 与 checksum,API 发布只处理 `api-server` 与 checksum,Stdb 发布只处理 `spacetime_module.wasm` 与 checksum。 diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 1a6d7ccd..d3c50f4b 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -31,6 +31,8 @@ pipeline { booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'Stdb 发布目标 URL;默认避开本机 Git/Web 使用的 3000 端口') + string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'Stdb 发布使用的 spacetime CLI root-dir') + string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: 'Stdb 发布使用的本机用户') } stages { @@ -136,6 +138,8 @@ pipeline { string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'DATABASE', value: params.DATABASE), string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''), + string(name: 'SPACETIME_ROOT_DIR', value: params.SPACETIME_ROOT_DIR ?: '/stdb'), + string(name: 'SPACETIME_RUN_AS_USER', value: params.SPACETIME_RUN_AS_USER ?: 'spacetimedb'), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET), booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT), string(name: 'BUILD_JOB_NAME', value: params.STDB_BUILD_JOB_NAME), diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index bd00bb5f..5aee3862 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -23,6 +23,8 @@ pipeline { string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') + string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致') + string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布') } @@ -51,6 +53,14 @@ pipeline { if (!params.SPACETIME_SERVER?.trim() && !params.SPACETIME_SERVER_URL?.trim()) { error('SPACETIME_SERVER 与 SPACETIME_SERVER_URL 不能同时为空。') } + def spacetimeRootDir = params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb' + if (!(spacetimeRootDir ==~ /^\/(?!.*\.\.)[A-Za-z0-9._\/-]+$/)) { + error("SPACETIME_ROOT_DIR 必须是 Linux 绝对路径且不能包含 ..: ${spacetimeRootDir}") + } + def spacetimeRunAsUser = params.SPACETIME_RUN_AS_USER?.trim() + if (spacetimeRunAsUser && !(spacetimeRunAsUser ==~ /^[A-Za-z_][A-Za-z0-9_-]*$/)) { + error("SPACETIME_RUN_AS_USER 只能是本机用户名: ${spacetimeRunAsUser}") + } def spacetimeServerUrl = params.SPACETIME_SERVER_URL?.trim() if (spacetimeServerUrl && !(spacetimeServerUrl ==~ /^https?:\/\/[A-Za-z0-9._:-]+$/)) { error("SPACETIME_SERVER_URL 只能是 http(s) URL,且不能包含路径或 shell 特殊字符: ${spacetimeServerUrl}") @@ -111,6 +121,10 @@ pipeline { steps { script { def clearArg = params.CLEAR_DATABASE ? '--clear-database' : '' + def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\"" + def runAsArg = params.SPACETIME_RUN_AS_USER?.trim() + ? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\"" + : '' def serverArg = params.SPACETIME_SERVER_URL?.trim() ? "--server-url \"${params.SPACETIME_SERVER_URL.trim()}\"" : "--server \"${params.SPACETIME_SERVER}\"" @@ -121,6 +135,8 @@ pipeline { scripts/deploy/production-stdb-publish.sh \\ --source-dir "build/${params.BUILD_VERSION}" \\ --database "${params.DATABASE}" \\ + ${rootArg} \\ + ${runAsArg} \\ ${serverArg} \\ ${clearArg} ' diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 8ecb797e..c14028f9 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -5,11 +5,12 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server-url http://127.0.0.1:3101] [--server local] [--clear-database] + ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] 说明: 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。 + 默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。 失败时保留维护模式。 EOF } @@ -38,8 +39,11 @@ SOURCE_DIR="" DATABASE="" SERVER_ALIAS="local" SERVER_URL="http://127.0.0.1:3101" +SPACETIME_ROOT_DIR="/stdb" +RUN_AS_USER="spacetimedb" CLEAR_DATABASE=0 DEPLOY_COMPLETED=0 +PUBLISH_TMP_DIR="" while [[ $# -gt 0 ]]; do case "$1" in @@ -64,6 +68,14 @@ while [[ $# -gt 0 ]]; do SERVER_URL="${2:?缺少 --server-url 的值}" shift 2 ;; + --root-dir) + SPACETIME_ROOT_DIR="${2:?缺少 --root-dir 的值}" + shift 2 + ;; + --run-as-user) + RUN_AS_USER="${2:?缺少 --run-as-user 的值}" + shift 2 + ;; --clear-database) CLEAR_DATABASE=1 shift @@ -80,6 +92,16 @@ require_argument "${SOURCE_DIR}" "--source-dir" require_argument "${DATABASE}" "--database" validate_spacetime_database_name "${DATABASE}" +if [[ ! "${SPACETIME_ROOT_DIR}" == /* || "${SPACETIME_ROOT_DIR}" == *".."* ]]; then + echo "[production-stdb-publish] --root-dir 必须是 Linux 绝对路径且不能包含 ..: ${SPACETIME_ROOT_DIR}" >&2 + exit 1 +fi + +if [[ -n "${RUN_AS_USER}" && ! "${RUN_AS_USER}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "[production-stdb-publish] --run-as-user 只能是本机用户名: ${RUN_AS_USER}" >&2 + exit 1 +fi + if [[ ! -d "${SOURCE_DIR}" ]]; then echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2 exit 1 @@ -94,6 +116,9 @@ fi on_exit() { local exit_code=$? + if [[ -n "${PUBLISH_TMP_DIR}" && -d "${PUBLISH_TMP_DIR}" ]]; then + rm -rf "${PUBLISH_TMP_DIR}" + fi if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2 fi @@ -111,6 +136,7 @@ echo "[production-stdb-publish] 校验 wasm" ) PUBLISH_ARGS=( + --root-dir="${SPACETIME_ROOT_DIR}" publish "${DATABASE}" --bin-path "${SOURCE_DIR}/spacetime_module.wasm" @@ -128,11 +154,38 @@ if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then fi if [[ -n "${SERVER_URL}" ]]; then - echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}" + echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}, root=${SPACETIME_ROOT_DIR}" else - echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}" + echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}, root=${SPACETIME_ROOT_DIR}" +fi + +if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then + if ! id "${RUN_AS_USER}" >/dev/null 2>&1; then + echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2 + exit 1 + fi + PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)" + install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm" + chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}" + PUBLISH_ARGS=( + --root-dir="${SPACETIME_ROOT_DIR}" + publish + "${DATABASE}" + --bin-path "${PUBLISH_TMP_DIR}/spacetime_module.wasm" + --yes + ) + if [[ -n "${SERVER_URL}" ]]; then + PUBLISH_ARGS+=(--server "${SERVER_URL}") + else + PUBLISH_ARGS+=(--server "${SERVER_ALIAS}") + fi + if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + PUBLISH_ARGS+=(--clear-database) + fi + runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}" +else + spacetime "${PUBLISH_ARGS[@]}" fi -spacetime "${PUBLISH_ARGS[@]}" "${SCRIPT_DIR}/maintenance-off.sh" DEPLOY_COMPLETED=1 From cc38057c3c1c4de460baa3328238615889f307a1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 03:38:10 +0800 Subject: [PATCH 25/25] chore: harden spacetime publish provisioning --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 +- .../Jenkinsfile.production-server-provision | 110 ++++++++++++++++++ scripts/deploy/production-stdb-publish.sh | 3 + 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 6a317d95..8d0b246f 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -392,6 +392,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 可选安装 Nginx 配置和维护模式 snippet。 - 安装 Nginx 配置时执行 `nginx -t`,通过后必须执行 `nginx -s reload`,确保新配置对当前 Nginx master/worker 生效。 - 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`;重启 `spacetimedb.service` 后必须等待 `http://127.0.0.1:3101/v1/ping` 确认就绪,不能只依赖 `systemctl restart` 的返回码。`` 下所有运行态文件必须归属 `spacetimedb:spacetimedb`。不要在 root 身份下对同一个 `` 执行 `spacetime --root-dir= server ping`,否则会生成 root-owned CLI 配置,导致 `spacetimedb` 服务用户后续启动时遇到权限错误。 +- 首次初始化时,如果 `/etc/genarrative/api-server.env` 里还没有 `GENARRATIVE_SPACETIME_TOKEN`,流水线会在 `spacetimedb.service` 就绪后调用本机 `POST http://127.0.0.1:3101/v1/identity` 生成 client identity/token,只把 token 写入环境文件,并只在日志里显示 identity 前缀。随后流水线会以 `spacetimedb` 用户执行 `/bin/current/spacetimedb-cli --root-dir login --token [REDACTED]`,确保后续首次 `Stdb publish` 使用同一个 client identity 创建数据库;这个 identity 才会成为后台读取 private 表所需的 owner。若环境文件已有 `GENARRATIVE_SPACETIME_TOKEN`,初始化必须保留该值,只同步 CLI 登录态,不重新生成或覆盖。 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 @@ -452,8 +453,9 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 发布脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 - 进入维护模式。 - 将 wasm 上传到生产实例。 -- 在生产实例本机执行 `spacetime --root-dir=/stdb publish --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes`。 +- 在生产实例本机执行 `spacetime --root-dir=/stdb publish --server http://127.0.0.1:3101 --bin-path spacetime_module.wasm --yes --no-config`。 - 发布动作默认以 `spacetimedb` 服务用户执行,避免 root 默认 CLI 身份对自托管数据库验签失败,也避免 root 写入 `/stdb/config` 造成后续服务用户启动权限错误。 +- `Stdb publish` 固定追加 `--no-config`,只依赖显式传入的 `--root-dir`、`--server`、`--bin-path` 与数据库名,避免 agent 工作区、本机用户目录或仓库内 `spacetime` 配置干扰发布目标。 - 成功后执行必要 smoke test。 - 成功后解除维护模式。 - 失败时保留维护模式并发邮件。 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 3c604947..631bcc30 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -284,6 +284,115 @@ pipeline { exit 1 } + read_env_value() { + local file="$1" + local key="$2" + local line value + + if [[ ! -f "${file}" ]]; then + return + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" == "${key}="* ]]; then + value="${line#*=}" + value="$(printf "%s" "${value}" | tr -d "\\r")" + if [[ "${value}" == \"* && "${value}" == *\" ]]; then + value="${value#\"}" + value="${value%\"}" + fi + printf "%s" "${value}" + return + fi + done <"${file}" + } + + write_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp updated line + + tmp="$(mktemp)" + updated="false" + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" == "${key}="* ]]; then + if [[ "${updated}" != "true" ]]; then + printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" + updated="true" + fi + else + printf "%s\\n" "${line}" >>"${tmp}" + fi + done <"${file}" + if [[ "${updated}" != "true" ]]; then + printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" + fi + + cat "${tmp}" >"${file}" + rm -f "${tmp}" + chmod 0600 "${file}" + chown root:root "${file}" + } + + parse_json_string_field() { + local json="$1" + local key="$2" + + printf "%s" "${json}" | sed -n "s/.*\\\"${key}\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\\\"]*\\)\\\".*/\\1/p" | head -n 1 + } + + ensure_spacetime_owner_client_token() { + local server_url="http://127.0.0.1:3101" + local cli_path="${SPACETIME_ROOT}/bin/current/spacetimedb-cli" + local token identity response login_output existing_token identity_preview + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure GENARRATIVE_SPACETIME_TOKEN in ${API_ENV_FILE}" + echo "+ generate SpacetimeDB client identity when token is missing" + echo "+ runuser -u spacetimedb -- ${cli_path} --root-dir ${SPACETIME_ROOT} login --token [REDACTED]" + return + fi + + if [[ ! -f "${API_ENV_FILE}" ]]; then + echo "[server-provision] 环境文件不存在,无法写入 GENARRATIVE_SPACETIME_TOKEN: ${API_ENV_FILE}" >&2 + exit 1 + fi + if [[ ! -x "${cli_path}" ]]; then + echo "[server-provision] SpacetimeDB CLI 不存在或不可执行: ${cli_path}" >&2 + exit 1 + fi + + existing_token="$(read_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN")" + if [[ -n "${existing_token}" ]]; then + token="${existing_token}" + echo "[server-provision] GENARRATIVE_SPACETIME_TOKEN 已存在,保留并同步 SpacetimeDB CLI 登录态。" + else + response="$(curl -fsS -X POST "${server_url}/v1/identity")" + identity="$(parse_json_string_field "${response}" "identity")" + identity="${identity:-$(parse_json_string_field "${response}" "Identity")}" + identity="${identity:-$(parse_json_string_field "${response}" "identity_hex")}" + identity="${identity:-$(parse_json_string_field "${response}" "identityHex")}" + token="$(parse_json_string_field "${response}" "token")" + token="${token:-$(parse_json_string_field "${response}" "Token")}" + if [[ -z "${identity}" || -z "${token}" ]]; then + echo "[server-provision] 生成 SpacetimeDB client identity 失败,响应缺少 identity/token。" >&2 + exit 1 + fi + + write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN" "${token}" + identity_preview="${identity:0:12}" + echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..." + fi + + if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then + echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2 + printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2 + exit 1 + fi + echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。" + } + render_nginx_https_config() { sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf } @@ -506,6 +615,7 @@ pipeline { run_cmd systemctl enable spacetimedb.service genarrative-api.service run_cmd systemctl restart spacetimedb.service wait_for_spacetimedb_service + ensure_spacetime_owner_client_token if [[ -x "${CURRENT_LINK}/api-server" ]]; then run_cmd systemctl restart genarrative-api.service else diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index c14028f9..38b3e73f 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -11,6 +11,7 @@ usage() { 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。 默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。 + 发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。 失败时保留维护模式。 EOF } @@ -141,6 +142,7 @@ PUBLISH_ARGS=( "${DATABASE}" --bin-path "${SOURCE_DIR}/spacetime_module.wasm" --yes + --no-config ) if [[ -n "${SERVER_URL}" ]]; then @@ -173,6 +175,7 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then "${DATABASE}" --bin-path "${PUBLISH_TMP_DIR}/spacetime_module.wasm" --yes + --no-config ) if [[ -n "${SERVER_URL}" ]]; then PUBLISH_ARGS+=(--server "${SERVER_URL}")