master #14
76
deploy/env/api-server.env.example
vendored
Normal file
76
deploy/env/api-server.env.example
vendored
Normal file
@@ -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:3101
|
||||
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
|
||||
87
deploy/nginx/genarrative-dev-http.conf
Normal file
87
deploy/nginx/genarrative-dev-http.conf
Normal file
@@ -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:3101;
|
||||
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:3101;
|
||||
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;
|
||||
}
|
||||
}
|
||||
101
deploy/nginx/genarrative.conf
Normal file
101
deploy/nginx/genarrative.conf
Normal file
@@ -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:3101;
|
||||
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:3101;
|
||||
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;
|
||||
}
|
||||
}
|
||||
12
deploy/nginx/snippets/genarrative-maintenance.conf
Normal file
12
deploy/nginx/snippets/genarrative-maintenance.conf
Normal file
@@ -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;
|
||||
}
|
||||
26
deploy/systemd/genarrative-api.service
Normal file
26
deploy/systemd/genarrative-api.service
Normal file
@@ -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
|
||||
23
deploy/systemd/spacetimedb.service
Normal file
23
deploy/systemd/spacetimedb.service
Normal file
@@ -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:3101
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
|
||||
# 生产库只监听本机端口,由 Nginx 暴露最小客户端路由。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/stdb
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -13,7 +13,7 @@
|
||||
重点补充: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)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
|
||||
@@ -22,7 +22,7 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
|
||||
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)。
|
||||
|
||||
## 分类规则
|
||||
|
||||
@@ -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. 风险与边界
|
||||
|
||||
|
||||
@@ -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 别名,默认 `dev`。
|
||||
3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`。
|
||||
4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`。
|
||||
5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `<DEPLOY_DIRECTORY>/.spacetimedb`。
|
||||
6. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。
|
||||
7. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`。
|
||||
8. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-<BUILD_NUMBER>.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-<BUILD_NUMBER>.json`。
|
||||
12. `TOKEN_CREDENTIAL_ID`:可选,已授权迁移 operator token 的 Jenkins Secret Text 凭据 ID。
|
||||
13. `BOOTSTRAP_SECRET_CREDENTIAL_ID`:可选,迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID。
|
||||
|
||||
导出成功后,Jenkins 归档:
|
||||
|
||||
```text
|
||||
<OUTPUT_DIRECTORY>/<EXPORT_NAME>
|
||||
<WORKSPACE_EXPORT_DIRECTORY>/<EXPORT_NAME>
|
||||
<WORKSPACE_EXPORT_DIRECTORY>/<EXPORT_NAME>.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
|
||||
```
|
||||
|
||||
570
docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md
Normal file
570
docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# 生产部署计划
|
||||
|
||||
更新时间:2026-05-02
|
||||
|
||||
## 当前落地进度
|
||||
|
||||
已落地生产基础设施骨架与首批生产 Jenkinsfile:
|
||||
|
||||
- `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`
|
||||
- `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`
|
||||
- `jenkins/Jenkinsfile.production-notify-email`
|
||||
- `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:3101`,数据根目录为 `/stdb`。`3000` 保留给部署机本机 Git/Web 服务,禁止再让 SpacetimeDB 占用该端口。
|
||||
- Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。
|
||||
- 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。
|
||||
- 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。
|
||||
|
||||
## 服务器目录
|
||||
|
||||
- `/opt/genarrative/releases/<version>/`:每次发布的完整版本目录。
|
||||
- `/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:3101`
|
||||
- 对外暴露:默认不直接暴露公网端口。
|
||||
|
||||
该方案与 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:`production-https` 模式只保留 301 重定向。
|
||||
- `/maintenance.html`:维护中页面。
|
||||
|
||||
移除这些公网反向代理:
|
||||
|
||||
- `/api/*`
|
||||
- `/generated-*`
|
||||
- 公网 `/healthz`
|
||||
- 其他旧的一体化 web server 代理入口。
|
||||
|
||||
SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。
|
||||
|
||||
Nginx 配置文件分为两类:
|
||||
|
||||
- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live/<SERVER_NAME>/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` 控制:
|
||||
|
||||
- 文件存在:进入维护模式。
|
||||
- 文件不存在:退出维护模式。
|
||||
|
||||
行为:
|
||||
|
||||
- 网站静态资源发布不进入维护模式。
|
||||
- `api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。
|
||||
- 普通页面在维护模式下展示 `/maintenance.html`。
|
||||
- `/admin/api/*` 在维护模式下返回 503。
|
||||
- 静态资源仍允许访问,避免维护页样式和资源加载失败。
|
||||
- 发布成功后自动解除维护模式。
|
||||
- 发布失败时保持维护模式,并通过邮件通知人工处理。
|
||||
|
||||
## 构建产物
|
||||
|
||||
生产发布包构建入口:
|
||||
|
||||
```bash
|
||||
npm run build:production-release -- --name <version>
|
||||
```
|
||||
|
||||
每次构建产物按版本号归档:
|
||||
|
||||
```text
|
||||
build/<version>/
|
||||
├─ 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
|
||||
│ │ ├─ genarrative-dev-http.conf
|
||||
│ │ └─ snippets/genarrative-maintenance.conf
|
||||
│ └─ env/api-server.env.example
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
`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/<job>/<build>/<version>/`,Jenkins 只归档 `web.tar.gz.sha256`、`release-manifest.json` 与 `web-artifact-pointer.txt`。`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`。
|
||||
- 这里的 `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` 拉取和校验目标提交。
|
||||
|
||||
`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`
|
||||
11. `Genarrative-Notify-Email`
|
||||
|
||||
已落地的生产流水线脚本文件:
|
||||
|
||||
- `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`
|
||||
- `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 插件。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 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。
|
||||
|
||||
持久收件人在 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` 参数,用于选择逻辑部署目标:
|
||||
|
||||
- 默认值:`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/{api-server,stdb-module}
|
||||
chmod 700 /var/cache/genarrative-build
|
||||
```
|
||||
|
||||
API 构建流水线建议设置:
|
||||
|
||||
```groovy
|
||||
environment {
|
||||
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/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'
|
||||
}
|
||||
```
|
||||
|
||||
如使用数据盘,则把上述路径替换为:
|
||||
|
||||
```groovy
|
||||
environment {
|
||||
CARGO_HOME = '/data/jenkins-cache/genarrative/<component>/cargo-home'
|
||||
CARGO_TARGET_DIR = '/data/jenkins-cache/genarrative/<component>/cargo-target/prod-release'
|
||||
CARGO_INCREMENTAL = '0'
|
||||
|
||||
RUSTC_WRAPPER = 'sccache'
|
||||
SCCACHE_DIR = '/data/jenkins-cache/genarrative/<component>/sccache'
|
||||
SCCACHE_CACHE_SIZE = '30G'
|
||||
}
|
||||
```
|
||||
|
||||
其中 `<component>` 使用 `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]` 读取构建器配置。
|
||||
|
||||
由于 `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 产物。脚本中的产物路径应按以下口径计算:
|
||||
|
||||
```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()`,避免同一组件的多个 release 构建同时写入同一最终产物路径。
|
||||
- 如果 Linux agent 未安装 `sccache`,应先运行 `Genarrative-Server-Provision` 补齐缓存工具;Rust 构建流水线仍必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。
|
||||
- 生产发布流水线只能消费 `build/<version>/` 或 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/<SOURCE_BRANCH>` 最新 commit;填写时必须是 7 到 40 位十六进制 hash,并且该 commit 必须属于 `origin/<SOURCE_BRANCH>`。
|
||||
- `DEPLOY_TARGET`:逻辑部署目标选择参数。发布流水线和 `Genarrative-Full-Build-And-Deploy` 必填;构建流水线仅在 `PUBLISH_AFTER_BUILD=true` 时用于触发下游发布。
|
||||
|
||||
执行规则:
|
||||
|
||||
- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行 `git fetch --tags --prune origin "+refs/heads/<SOURCE_BRANCH>:refs/remotes/origin/<SOURCE_BRANCH>"`。
|
||||
- 如果工作区是浅克隆,流水线必须尝试 `git fetch --unshallow --tags`,确保能验证目标 commit 与分支关系。
|
||||
- `COMMIT_HASH` 为空时,detached checkout 到 `refs/remotes/origin/<SOURCE_BRANCH>` 当前最新 commit。
|
||||
- `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor <commit> refs/remotes/origin/<SOURCE_BRANCH>` 校验该提交属于指定分支,校验通过后 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。
|
||||
- 安装 SpacetimeDB 时不能只复制 `/usr/local/bin/spacetime` wrapper,还必须把 `spacetimedb-cli` 与 `spacetimedb-standalone` 同步到 `<SPACETIME_ROOT>/bin/current/`。否则 `spacetime --root-dir=<SPACETIME_ROOT> start` 会回调缺失的 `<SPACETIME_ROOT>/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` 后必须等待 `http://127.0.0.1:3101/v1/ping` 确认就绪,不能只依赖 `systemctl restart` 的返回码。`<SPACETIME_ROOT>` 下所有运行态文件必须归属 `spacetimedb:spacetimedb`。不要在 root 身份下对同一个 `<SPACETIME_ROOT>` 执行 `spacetime --root-dir=<SPACETIME_ROOT> 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` 用户执行 `<SPACETIME_ROOT>/bin/current/spacetimedb-cli --root-dir <SPACETIME_ROOT> 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。
|
||||
|
||||
首次真实初始化默认保持 `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/<SERVER_NAME>/fullchain.pem` 与 `/etc/letsencrypt/live/<SERVER_NAME>/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`。
|
||||
|
||||
### Web Build / Deploy
|
||||
|
||||
构建:
|
||||
|
||||
- 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 目标源码,默认构建 `origin/master` 最新 commit。
|
||||
- 默认执行 `npm ci` 安装前端依赖,确保 Jenkins 新 workspace 中存在 `vite` 等构建工具。
|
||||
- 构建主站静态文件。
|
||||
- 构建后台前端,base path 为 `/admin/`。
|
||||
- 生成或复制 `maintenance.html`。
|
||||
- 将 `web/` 打包为 `web.tar.gz`,生成 `web.tar.gz.sha256`。
|
||||
- 将 `web.tar.gz`、`web.tar.gz.sha256` 和 `release-manifest.json` 复制到 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/`;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。
|
||||
- 通过 Jenkins 归档获取 `web.tar.gz.sha256`、`release-manifest.json` 和 `web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/` 读取 `web.tar.gz`;先校验 checksum,再解压到 `/opt/genarrative/releases/<version>/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/<version>/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 --root-dir=/stdb publish <database-name> --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。
|
||||
- 成功后解除维护模式。
|
||||
- 失败时保留维护模式并发邮件。
|
||||
|
||||
### Full Build-And-Deploy
|
||||
|
||||
- 先解析一次最终 `SOURCE_COMMIT`,所有下游构建和发布都使用同一个分支与 commit。
|
||||
- 并行执行 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。
|
||||
|
||||
## 数据库导出与导入
|
||||
|
||||
### 导出
|
||||
|
||||
`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_URL=http://127.0.0.1:3101`,自托管 `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`;并行阶段开启 fail-fast,任一构建失败就中断其他仍在执行的构建分支。
|
||||
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/genarrative-dev-http.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`
|
||||
- [x] `jenkins/Jenkinsfile.production-notify-email`
|
||||
- [x] 所有生产流水线通过 `NOTIFICATION_EMAILS` 参数在结束时触发 `Genarrative-Notify-Email`
|
||||
|
||||
## 参考
|
||||
|
||||
- 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/<database>/subscribe`,其他发布、SQL、管理类入口保持本机可用。
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。
|
||||
- [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
|
||||
- [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。
|
||||
- [RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md](./RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md):记录 RPG 角色主图与场景幕背景图统一迁移到 APIMart OpenAI 兼容 `gpt-image-2` 生图入口的边界、配置和验收口径。
|
||||
@@ -69,7 +70,7 @@
|
||||
- [MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md](./MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md):记录抓大鹅运行态 3D 几何体与物理碰撞实验的前端表现层边界、2D 回退要求和验收口径。
|
||||
- [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 的落地边界。
|
||||
@@ -132,9 +133,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 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单;`/generated-*` 直读代理已下线。
|
||||
- [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 增量解析、错误模型与重试边界。
|
||||
@@ -258,11 +259,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/` 一起看,更容易判断先后顺序。
|
||||
|
||||
@@ -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 切流前需要的两类工程脚本:
|
||||
|
||||
@@ -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”,而是同时解决下面几类问题:
|
||||
|
||||
@@ -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/<database>')
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 'dev', description: 'SpacetimeDB server 别名,例如 dev/local')
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 'dev', description: 'SpacetimeDB server 别名,例如 dev/local')
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<database>')
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
144
jenkins/Jenkinsfile.production-api-build
Normal file
144
jenkins/Jenkinsfile.production-api-build
Normal file
@@ -0,0 +1,144 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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/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/api-server/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')
|
||||
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')
|
||||
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:-master}" \
|
||||
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
|
||||
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
|
||||
fi
|
||||
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: '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),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
148
jenkins/Jenkinsfile.production-api-deploy
Normal file
148
jenkins/Jenkinsfile.production-api-deploy
Normal file
@@ -0,0 +1,148 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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: '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: '要复制归档产物的上游构建号')
|
||||
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:-master}" \
|
||||
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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
227
jenkins/Jenkinsfile.production-database-export
Normal file
227
jenkins/Jenkinsfile.production-database-export
Normal file
@@ -0,0 +1,227 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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: '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: '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 内的导出目录,用于归档')
|
||||
string(name: 'SERVER_BACKUP_DIRECTORY', defaultValue: '/var/lib/genarrative/database-exports', description: '可选,额外保存在目标机器上的备份目录;留空则不保存服务器副本')
|
||||
string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则使用 spacetime-migration-<BUILD_NUMBER>.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:-master}" \
|
||||
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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
344
jenkins/Jenkinsfile.production-database-import
Normal file
344
jenkins/Jenkinsfile.production-database-import
Normal file
@@ -0,0 +1,344 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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: '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: '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')
|
||||
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:-master}" \
|
||||
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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
221
jenkins/Jenkinsfile.production-full-build-and-deploy
Normal file
221
jenkins/Jenkinsfile.production-full-build-and-deploy
Normal file
@@ -0,0 +1,221 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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')
|
||||
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
|
||||
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 构建流水线作业名')
|
||||
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')
|
||||
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 {
|
||||
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:-master}" \
|
||||
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') {
|
||||
// 任一构建分支失败时立即中断其他并行分支,避免失败后继续消耗构建资源。
|
||||
failFast true
|
||||
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),
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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),
|
||||
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||
]
|
||||
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: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
|
||||
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: '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),
|
||||
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: '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),
|
||||
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: '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),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.WEB_BUILD_NUMBER),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
79
jenkins/Jenkinsfile.production-notify-email
Normal file
79
jenkins/Jenkinsfile.production-notify-email
Normal file
@@ -0,0 +1,79 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '0'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
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')
|
||||
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 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 均未配置,跳过邮件发送。'
|
||||
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] 已发送邮件: recipientCount=${recipientList.size()}, 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('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
666
jenkins/Jenkinsfile.production-server-provision
Normal file
666
jenkins/Jenkinsfile.production-server-provision
Normal file
@@ -0,0 +1,666 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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')
|
||||
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: '部署脚本来源分支')
|
||||
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 本机监听端口')
|
||||
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 服务')
|
||||
}
|
||||
|
||||
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 不能为空。')
|
||||
}
|
||||
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。')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:-master}" \
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsS "${server_url}/v1/ping" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}|" \
|
||||
-e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101|" \
|
||||
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 配置。请填写真实域名,或先设置 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] 请先完成证书申请,或首次初始化时设置 NGINX_CONFIG_MODE=none,避免写入无法通过 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 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
|
||||
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
|
||||
echo "+ nginx -t"
|
||||
echo "+ nginx -s reload"
|
||||
return
|
||||
fi
|
||||
|
||||
rendered_config="$(mktemp)"
|
||||
rendered_snippet="$(mktemp)"
|
||||
config_backup="$(mktemp)"
|
||||
snippet_backup="$(mktemp)"
|
||||
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
|
||||
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
|
||||
echo "+ nginx -s reload"
|
||||
nginx -s reload
|
||||
|
||||
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
|
||||
else
|
||||
echo "+ nginx -s reload"
|
||||
nginx -s reload
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
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/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}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
|
||||
|
||||
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
|
||||
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
|
||||
sync_spacetime_install "${SPACETIME_ROOT}"
|
||||
|
||||
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 [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then
|
||||
install_nginx_config_with_rollback
|
||||
else
|
||||
cleanup_placeholder_nginx_config
|
||||
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
|
||||
wait_for_spacetimedb_service
|
||||
ensure_spacetime_owner_client_token
|
||||
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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
146
jenkins/Jenkinsfile.production-stdb-module-build
Normal file
146
jenkins/Jenkinsfile.production-stdb-module-build
Normal file
@@ -0,0 +1,146 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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/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'
|
||||
}
|
||||
|
||||
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: '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')
|
||||
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:-master}" \
|
||||
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
|
||||
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
|
||||
fi
|
||||
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: '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),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER),
|
||||
string(name: 'DATABASE', value: params.DATABASE),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
182
jenkins/Jenkinsfile.production-stdb-module-publish
Normal file
182
jenkins/Jenkinsfile.production-stdb-module-publish
Normal file
@@ -0,0 +1,182 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
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: '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: '要复制归档产物的上游构建号')
|
||||
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: '是否清空数据库后发布')
|
||||
}
|
||||
|
||||
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 不能为空。')
|
||||
}
|
||||
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}")
|
||||
}
|
||||
def spacetimeServer = params.SPACETIME_SERVER?.trim()
|
||||
if (!spacetimeServerUrl && spacetimeServer && !(spacetimeServer ==~ /^[A-Za-z0-9._:-]+$/)) {
|
||||
error("SPACETIME_SERVER 只能包含字母、数字、点、下划线、冒号和短横线: ${spacetimeServer}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:-master}" \
|
||||
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' : ''
|
||||
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}\""
|
||||
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}" \\
|
||||
${rootArg} \\
|
||||
${runAsArg} \\
|
||||
${serverArg} \\
|
||||
${clearArg}
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
157
jenkins/Jenkinsfile.production-web-build
Normal file
157
jenkins/Jenkinsfile.production-web-build
Normal file
@@ -0,0 +1,157 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
|
||||
}
|
||||
|
||||
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: '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 发布流水线作业名')
|
||||
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:-master}" \
|
||||
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 {
|
||||
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" <<EOF
|
||||
WEB_ARTIFACT_DIR=${artifact_dir}
|
||||
WEB_ARTIFACT_JOB=${JOB_NAME}
|
||||
WEB_ARTIFACT_BUILD_NUMBER=${BUILD_NUMBER}
|
||||
WEB_ARTIFACT_VERSION=${EFFECTIVE_BUILD_VERSION}
|
||||
EOF
|
||||
|
||||
echo "[web-build] Web 大包已保存在构建机本地目录: ${artifact_dir}"
|
||||
find "${WEB_ARTIFACT_ROOT}/${JOB_NAME}" -mindepth 1 -maxdepth 1 -type d -mtime +14 -print -exec rm -rf {} +
|
||||
'
|
||||
'''
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/web.tar.gz.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/web-artifact-pointer.txt", fingerprint: false
|
||||
}
|
||||
}
|
||||
|
||||
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: '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),
|
||||
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.BUILD_NUMBER),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
jenkins/Jenkinsfile.production-web-deploy
Normal file
169
jenkins/Jenkinsfile.production-web-deploy
Normal file
@@ -0,0 +1,169 @@
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
|
||||
}
|
||||
|
||||
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: '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: '要复制归档产物的上游构建号')
|
||||
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:-master}" \
|
||||
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.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/web-artifact-pointer.txt",
|
||||
target: '.',
|
||||
fingerprintArtifacts: true
|
||||
)
|
||||
sh '''
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}"
|
||||
if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then
|
||||
echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&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}"
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||
"api-server": "node scripts/api-server-dev.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",
|
||||
|
||||
413
scripts/build-production-release.sh
Normal file
413
scripts/build-production-release.sh
Normal file
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
npm run build:production-release -- --name <version>
|
||||
|
||||
说明:
|
||||
1. 生成生产发布包 build/<version>/。
|
||||
2. 发布包只包含 systemd + Nginx 生产部署需要的文件,不再生成旧 start.sh / stop.sh / web-server.mjs。
|
||||
3. 默认会构建主站、后台、api-server 与 SpacetimeDB wasm;调试时可用 skip 参数复用已存在产物。
|
||||
|
||||
参数:
|
||||
--name <version> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
|
||||
--component <name> 构建组件: 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'
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>服务维护中</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #101418;
|
||||
color: #f3f7fb;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
max-width: 28rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b6c2cf;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>服务维护中</h1>
|
||||
<p>我们正在更新系统,稍后请重新访问。</p>
|
||||
</main>
|
||||
</body>
|
||||
</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" <<EOF
|
||||
# Genarrative Production Release
|
||||
|
||||
版本:\`${BUILD_NAME}\`
|
||||
|
||||
## 内容
|
||||
|
||||
- \`web/\`:主站静态资源,\`web/admin/\` 为后台静态资源,\`web/maintenance.html\` 为维护页。
|
||||
- \`web.tar.gz\` / \`web.tar.gz.sha256\`:Web 发布流水线使用的静态资源压缩包与校验文件。
|
||||
- \`api-server\`:生产 Linux release 可执行文件。
|
||||
- \`spacetime_module.wasm\`:SpacetimeDB 模块 wasm。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本和迁移授权脚本。
|
||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||
|
||||
## 生产部署口径
|
||||
|
||||
本发布包不包含旧一体化 \`start.sh\`、\`stop.sh\` 或 \`web-server.mjs\`。
|
||||
生产环境由 systemd 托管 \`spacetimedb.service\` 与 \`genarrative-api.service\`,由 Nginx 托管 \`web/\`。
|
||||
EOF
|
||||
|
||||
write_release_manifest
|
||||
|
||||
BUILD_COMPLETED=1
|
||||
echo "[production-release] 完成: ${TARGET_DIR}"
|
||||
12
scripts/deploy/maintenance-off.sh
Normal file
12
scripts/deploy/maintenance-off.sh
Normal file
@@ -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
|
||||
rm -f "${MAINTENANCE_FILE}"
|
||||
echo "[maintenance] 已退出维护模式: ${MAINTENANCE_FILE}"
|
||||
else
|
||||
echo "[maintenance] 当前未处于维护模式: ${MAINTENANCE_FILE}"
|
||||
fi
|
||||
15
scripts/deploy/maintenance-on.sh
Normal file
15
scripts/deploy/maintenance-on.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
REASON="${*:-manual}"
|
||||
|
||||
mkdir -p "$(dirname "${MAINTENANCE_FILE}")"
|
||||
{
|
||||
printf "enabled_at=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf "reason=%s\n" "${REASON}"
|
||||
} >"${MAINTENANCE_FILE}"
|
||||
|
||||
chmod 0644 "${MAINTENANCE_FILE}"
|
||||
echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}"
|
||||
12
scripts/deploy/maintenance-status.sh
Normal file
12
scripts/deploy/maintenance-status.sh
Normal file
@@ -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
|
||||
138
scripts/deploy/production-api-deploy.sh
Normal file
138
scripts/deploy/production-api-deploy.sh
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <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
|
||||
195
scripts/deploy/production-stdb-publish.sh
Normal file
195
scripts/deploy/production-stdb-publish.sh
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <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 身份污染自托管实例。
|
||||
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
|
||||
失败时保留维护模式。
|
||||
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"
|
||||
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
|
||||
-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 的值}"
|
||||
SERVER_URL=""
|
||||
shift 2
|
||||
;;
|
||||
--server-url)
|
||||
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
|
||||
;;
|
||||
*)
|
||||
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 [[ ! "${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
|
||||
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 [[ -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
|
||||
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=(
|
||||
--root-dir="${SPACETIME_ROOT_DIR}"
|
||||
publish
|
||||
"${DATABASE}"
|
||||
--bin-path "${SOURCE_DIR}/spacetime_module.wasm"
|
||||
--yes
|
||||
--no-config
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
if [[ -n "${SERVER_URL}" ]]; then
|
||||
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}, root=${SPACETIME_ROOT_DIR}"
|
||||
else
|
||||
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
|
||||
--no-config
|
||||
)
|
||||
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
|
||||
|
||||
"${SCRIPT_DIR}/maintenance-off.sh"
|
||||
DEPLOY_COMPLETED=1
|
||||
echo "[production-stdb-publish] 完成"
|
||||
123
scripts/deploy/production-web-deploy.sh
Normal file
123
scripts/deploy/production-web-deploy.sh
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-web-deploy.sh --source-dir build/<version> [--version <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/<version>/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}"
|
||||
54
scripts/jenkins-checkout-source.sh
Normal file
54
scripts/jenkins-checkout-source.sh
Normal file
@@ -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}"
|
||||
44
scripts/jenkins-prepare-cargo-env.sh
Normal file
44
scripts/jenkins-prepare-cargo-env.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/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
|
||||
|
||||
# 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
|
||||
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
|
||||
3
server-rs/.cargo/config.toml
Normal file
3
server-rs/.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user