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

服务维护中

+

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

+
+ + +MAINTENANCE_HTML + fi + + echo "[production-release] 打包 Web 静态资源 -> ${TARGET_DIR}/web.tar.gz" + tar -czf "${TARGET_DIR}/web.tar.gz" -C "${TARGET_DIR}" web + write_sha256_file "${TARGET_DIR}/web.tar.gz" +fi + +if [[ "${BUILD_API}" -eq 1 && "${SKIP_API_BUILD}" -ne 1 ]]; then + echo "[production-release] 构建 api-server -> x86_64-unknown-linux-gnu" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p api-server \ + --release \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +if [[ "${BUILD_API}" -eq 1 ]]; then + copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary" + chmod +x "${TARGET_DIR}/api-server" + write_sha256_file "${TARGET_DIR}/api-server" +fi + +if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then + echo "[production-release] 构建 spacetime-module -> wasm32-unknown-unknown" + ( + cd "${SERVER_RS_DIR}" + cargo build \ + -p spacetime-module \ + --release \ + --target wasm32-unknown-unknown \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" + ) +fi + +if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then + copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm" + write_sha256_file "${TARGET_DIR}/spacetime_module.wasm" +fi + +mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy" +cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh" +cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh" +cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh" +chmod +x \ + "${TARGET_DIR}/scripts/maintenance-on.sh" \ + "${TARGET_DIR}/scripts/maintenance-off.sh" \ + "${TARGET_DIR}/scripts/maintenance-status.sh" + +copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本" + +copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置" +copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置" +copy_required_dir "${REPO_ROOT}/deploy/env" "${TARGET_DIR}/deploy/env" "生产环境示例" + +cat >"${TARGET_DIR}/README.md" <"${MAINTENANCE_FILE}" + +chmod 0644 "${MAINTENANCE_FILE}" +echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}" diff --git a/scripts/deploy/maintenance-status.sh b/scripts/deploy/maintenance-status.sh new file mode 100644 index 00000000..bbb4661e --- /dev/null +++ b/scripts/deploy/maintenance-status.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" + +if [[ -f "${MAINTENANCE_FILE}" ]]; then + echo "[maintenance] enabled" + cat "${MAINTENANCE_FILE}" +else + echo "[maintenance] disabled" +fi diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh new file mode 100644 index 00000000..1ee32a16 --- /dev/null +++ b/scripts/deploy/production-api-deploy.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/healthz] + +说明: + 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。 + 失败时保留维护模式。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-api-deploy] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="" +VERSION="" +RELEASE_ROOT="/opt/genarrative/releases" +CURRENT_LINK="/opt/genarrative/current" +SERVICE_NAME="genarrative-api.service" +HEALTH_URL="http://127.0.0.1:8082/healthz" +DEPLOY_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --version) + VERSION="${2:?缺少 --version 的值}" + shift 2 + ;; + --release-root) + RELEASE_ROOT="${2:?缺少 --release-root 的值}" + shift 2 + ;; + --current-link) + CURRENT_LINK="${2:?缺少 --current-link 的值}" + shift 2 + ;; + --service) + SERVICE_NAME="${2:?缺少 --service 的值}" + shift 2 + ;; + --health-url) + HEALTH_URL="${2:?缺少 --health-url 的值}" + shift 2 + ;; + *) + echo "[production-api-deploy] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-api-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" +VERSION="${VERSION:-$(basename "${SOURCE_DIR}")}" + +if [[ ! "${VERSION}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[production-api-deploy] --version 只能包含数字、字母、点、下划线和短横线: ${VERSION}" >&2 + exit 1 +fi + +if [[ ! -f "${SOURCE_DIR}/api-server" || ! -f "${SOURCE_DIR}/api-server.sha256" ]]; then + echo "[production-api-deploy] 缺少 api-server 或 api-server.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then + echo "[production-api-deploy] 部署失败,保持维护模式。" >&2 + fi + exit "${exit_code}" +} + +trap on_exit EXIT + +"${SCRIPT_DIR}/maintenance-on.sh" "api deploy ${VERSION}" + +echo "[production-api-deploy] 校验 api-server" +( + cd "${SOURCE_DIR}" + sha256sum -c api-server.sha256 +) + +RELEASE_DIR="${RELEASE_ROOT}/${VERSION}" +mkdir -p "${RELEASE_DIR}" +cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server" +chmod +x "${RELEASE_DIR}/api-server" + +if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then + cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json" +fi + +mkdir -p "$(dirname "${CURRENT_LINK}")" +ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" + +echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" +systemctl restart "${SERVICE_NAME}" + +echo "[production-api-deploy] 等待 healthz: ${HEALTH_URL}" +for _ in {1..30}; do + if curl -fsS "${HEALTH_URL}" >/dev/null; then + "${SCRIPT_DIR}/maintenance-off.sh" + DEPLOY_COMPLETED=1 + echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server" + exit 0 + fi + sleep 2 +done + +echo "[production-api-deploy] healthz 检查超时: ${HEALTH_URL}" >&2 +exit 1 diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh new file mode 100644 index 00000000..0929fa96 --- /dev/null +++ b/scripts/deploy/production-stdb-publish.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server local] [--clear-database] + +说明: + 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 + 失败时保留维护模式。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-stdb-publish] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +validate_spacetime_database_name() { + local database="$1" + + if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "[production-stdb-publish] --database 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${database}" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="" +DATABASE="" +SERVER_ALIAS="local" +CLEAR_DATABASE=0 +DEPLOY_COMPLETED=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --database) + DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --server) + SERVER_ALIAS="${2:?缺少 --server 的值}" + shift 2 + ;; + --clear-database) + CLEAR_DATABASE=1 + shift + ;; + *) + echo "[production-stdb-publish] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" +require_argument "${DATABASE}" "--database" +validate_spacetime_database_name "${DATABASE}" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" + +if [[ ! -f "${SOURCE_DIR}/spacetime_module.wasm" || ! -f "${SOURCE_DIR}/spacetime_module.wasm.sha256" ]]; then + echo "[production-stdb-publish] 缺少 spacetime_module.wasm 或 spacetime_module.wasm.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +on_exit() { + local exit_code=$? + if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then + echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2 + fi + exit "${exit_code}" +} + +trap on_exit EXIT + +"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}" + +echo "[production-stdb-publish] 校验 wasm" +( + cd "${SOURCE_DIR}" + sha256sum -c spacetime_module.wasm.sha256 +) + +PUBLISH_ARGS=( + publish + "${DATABASE}" + --server "${SERVER_ALIAS}" + --bin-path "${SOURCE_DIR}/spacetime_module.wasm" + --yes +) + +if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + PUBLISH_ARGS+=(--clear-database) +fi + +echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}" +spacetime "${PUBLISH_ARGS[@]}" + +"${SCRIPT_DIR}/maintenance-off.sh" +DEPLOY_COMPLETED=1 +echo "[production-stdb-publish] 完成" diff --git a/scripts/deploy/production-web-deploy.sh b/scripts/deploy/production-web-deploy.sh new file mode 100644 index 00000000..c586823f --- /dev/null +++ b/scripts/deploy/production-web-deploy.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + ./scripts/deploy/production-web-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--web-link /srv/genarrative/web] [--current-link /opt/genarrative/current] + +说明: + 校验 web.tar.gz.sha256 后,把 web.tar.gz 解压到 /opt/genarrative/releases//web, + 并更新 /srv/genarrative/web 软链接。如果同版本目录已存在 api-server,则同步更新 /opt/genarrative/current。 +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[production-web-deploy] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +SOURCE_DIR="" +VERSION="" +RELEASE_ROOT="/opt/genarrative/releases" +CURRENT_LINK="/opt/genarrative/current" +WEB_LINK="/srv/genarrative/web" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --version) + VERSION="${2:?缺少 --version 的值}" + shift 2 + ;; + --release-root) + RELEASE_ROOT="${2:?缺少 --release-root 的值}" + shift 2 + ;; + --current-link) + CURRENT_LINK="${2:?缺少 --current-link 的值}" + shift 2 + ;; + --web-link) + WEB_LINK="${2:?缺少 --web-link 的值}" + shift 2 + ;; + *) + echo "[production-web-deploy] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[production-web-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" +VERSION="${VERSION:-$(basename "${SOURCE_DIR}")}" + +if [[ ! "${VERSION}" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "[production-web-deploy] --version 只能包含数字、字母、点、下划线和短横线: ${VERSION}" >&2 + exit 1 +fi + +if [[ ! -f "${SOURCE_DIR}/web.tar.gz" || ! -f "${SOURCE_DIR}/web.tar.gz.sha256" ]]; then + echo "[production-web-deploy] 缺少 web.tar.gz 或 web.tar.gz.sha256: ${SOURCE_DIR}" >&2 + exit 1 +fi + +echo "[production-web-deploy] 校验 Web 压缩包" +( + cd "${SOURCE_DIR}" + sha256sum -c web.tar.gz.sha256 +) + +RELEASE_DIR="${RELEASE_ROOT}/${VERSION}" +WEB_TARGET="${RELEASE_DIR}/web" +mkdir -p "${RELEASE_DIR}" +rm -rf "${WEB_TARGET}" + +echo "[production-web-deploy] 解压 Web 到: ${WEB_TARGET}" +tar -xzf "${SOURCE_DIR}/web.tar.gz" -C "${RELEASE_DIR}" +test -d "${WEB_TARGET}" + +if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then + cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.web.json" +fi + +mkdir -p "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" +ln -sfn "${WEB_TARGET}" "${WEB_LINK}" +if [[ -x "${RELEASE_DIR}/api-server" ]]; then + ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" +else + echo "[production-web-deploy] ${RELEASE_DIR}/api-server 不存在,仅更新 Web 软链接,保持 current 不变。" +fi + +if command -v nginx >/dev/null 2>&1; then + echo "[production-web-deploy] nginx -t" + nginx -t +fi + +if [[ ! -f "${WEB_TARGET}/index.html" ]]; then + echo "[production-web-deploy] Web smoke test 失败,缺少 index.html: ${WEB_TARGET}" >&2 + exit 1 +fi + +echo "[production-web-deploy] 完成: ${WEB_TARGET}" diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh new file mode 100644 index 00000000..dec8f64e --- /dev/null +++ b/scripts/jenkins-checkout-source.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SOURCE_BRANCH="${SOURCE_BRANCH:-master}" +COMMIT_HASH="${COMMIT_HASH:-}" +GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" +SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}" + +if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then + echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2 + exit 1 +fi + +if [[ "${SOURCE_BRANCH}" == /* || "${SOURCE_BRANCH}" == */ || "${SOURCE_BRANCH}" == *..* ]]; then + echo "[jenkins-checkout-source] SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号: ${SOURCE_BRANCH}" >&2 + exit 1 +fi + +if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + echo "[jenkins-checkout-source] COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash: ${COMMIT_HASH}" >&2 + exit 1 +fi + +if [[ -n "${GIT_REMOTE_URL}" ]]; then + git remote set-url origin "${GIT_REMOTE_URL}" +fi + +git reset --hard HEAD +git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + +if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then + git fetch --unshallow --tags || true +fi + +git cat-file -e "refs/remotes/origin/${SOURCE_BRANCH}^{commit}" + +if [[ -n "${COMMIT_HASH}" ]]; then + git cat-file -e "${COMMIT_HASH}^{commit}" + RESOLVED_COMMIT="$(git rev-parse "${COMMIT_HASH}^{commit}")" + if ! git merge-base --is-ancestor "${RESOLVED_COMMIT}" "refs/remotes/origin/${SOURCE_BRANCH}"; then + echo "[jenkins-checkout-source] 指定 commit 不属于 origin/${SOURCE_BRANCH}: ${RESOLVED_COMMIT}" >&2 + exit 1 + fi +else + RESOLVED_COMMIT="$(git rev-parse "refs/remotes/origin/${SOURCE_BRANCH}^{commit}")" +fi + +git checkout --detach "${RESOLVED_COMMIT}" +git reset --hard HEAD +git clean -fd + +printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}" +echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}" diff --git a/server-rs/.cargo/config.toml b/server-rs/.cargo/config.toml new file mode 100644 index 00000000..525e9b4e --- /dev/null +++ b/server-rs/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 07b28386..86070dca 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -52,7 +52,3 @@ http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } sha1 = "0.10" tower = { version = "0.5", features = ["util"] } - -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=lld"] # Ubuntu 22+ 自带 lld diff --git a/生产部署计划.md b/生产部署计划.md deleted file mode 100644 index c5f72169..00000000 --- a/生产部署计划.md +++ /dev/null @@ -1,316 +0,0 @@ -# 生产部署计划 - -更新时间:2026-05-02 - -## 目标 - -将当前部署方式调整为单机生产推荐方案:生产运行路径不使用 Docker,不再使用旧的一体化启动脚本,由 systemd 托管 SpacetimeDB 与 Rust `api-server`,由 Nginx 托管主站、后台前端与必要反向代理。 - -本计划用于重新创建 Jenkins 流水线、服务器环境配置、网站发布、`api-server` 发布、SpacetimeDB 模块发布,以及数据库人工导入导出流程。 - -## 生产架构 - -- Nginx 作为唯一公网入口,负责 HTTPS、静态站点、后台静态页面、维护页与 `/admin/api/` 反向代理。 -- SpacetimeDB 作为系统服务运行,监听 `127.0.0.1:3000`,数据根目录为 `/stdb`。 -- Rust `api-server` 作为系统服务运行,监听 `127.0.0.1:8082`,只被 Nginx 的 `/admin/api/` 访问。 -- 主站与后台前端构建为静态文件,发布到服务器固定目录,不放入 Jenkins 目录,也不跟随 Docker 镜像。 -- 除网站静态发布外,`api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更都必须先进入维护模式。 - -## 服务器目录 - -- `/opt/genarrative/releases//`:每次发布的完整版本目录。 -- `/opt/genarrative/current`:指向当前生效版本的软链接。 -- `/srv/genarrative/web`:指向 `/opt/genarrative/current/web`,供 Nginx 托管静态站点。 -- `/etc/genarrative/api-server.env`:`api-server` 生产环境变量文件。 -- `/var/lib/genarrative/maintenance/enabled`:维护模式开关文件。 -- `/stdb`:SpacetimeDB 程序、配置与数据根目录。 - -## 生产密钥 - -`/etc/genarrative/api-server.env` 中的生产密钥指所有只能存在于生产服务器、不能进入 Git、不能进入构建产物的敏感配置。典型内容包括: - -- LLM 或第三方服务 API Key。 -- 短信服务 Access Key 与 Secret。 -- 后台登录、会话、签名、加密相关密钥。 -- 生产 SpacetimeDB 地址与数据库名。 -- 只允许生产使用的回调地址、白名单或内部令牌。 - -该文件由服务器配置流水线或人工初始化创建,权限建议为 `root:genarrative`、`0640`。Jenkins 构建任务不能读取该文件;只有生产发布或服务启动需要读取。 - -## systemd 服务 - -### SpacetimeDB - -- 服务名:`spacetimedb.service` -- 运行用户:`spacetimedb` -- 工作目录:`/stdb` -- 启动命令:`/stdb/spacetime --root-dir=/stdb start --listen-addr=127.0.0.1:3000` -- 对外暴露:默认不直接暴露公网端口。 - -该方案与 SpacetimeDB 官方自托管文档一致:使用 Ubuntu、专用用户、`/stdb` 根目录、systemd 服务和 Nginx。 - -### api-server - -- 服务名:`genarrative-api.service` -- 运行用户:`genarrative` -- 工作目录:`/opt/genarrative/current` -- 可执行文件:`/opt/genarrative/current/api-server` -- 环境文件:`/etc/genarrative/api-server.env` -- 监听地址:`127.0.0.1:8082` - -`api-server` 不放入 Docker,也不直接暴露公网端口。发布时替换版本目录并重启 `genarrative-api.service`。 - -## Nginx 规则 - -只保留必要入口: - -- `/`:主站静态页面。 -- `/admin/`:后台前端静态页面,后台构建时使用 `/admin/` 作为 base path。 -- `/admin/api/`:反向代理到 `http://127.0.0.1:8082/admin/api/`。 -- HTTP 到 HTTPS:只保留 301 重定向。 -- `/maintenance.html`:维护中页面。 - -移除这些公网反向代理: - -- `/api/*` -- `/generated-*` -- 公网 `/healthz` -- 其他旧的一体化 web server 代理入口。 - -SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露最小集合。禁止开放可远程发布数据库或管理实例的通用入口。 - -## 维护模式 - -维护模式由 `/var/lib/genarrative/maintenance/enabled` 控制: - -- 文件存在:进入维护模式。 -- 文件不存在:退出维护模式。 - -行为: - -- 网站静态资源发布不进入维护模式。 -- `api-server` 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。 -- 普通页面在维护模式下展示 `/maintenance.html`。 -- `/admin/api/*` 在维护模式下返回 503。 -- 静态资源仍允许访问,避免维护页样式和资源加载失败。 -- 发布成功后自动解除维护模式。 -- 发布失败时保持维护模式,并通过邮件通知人工处理。 - -## 构建产物 - -每次构建产物按版本号归档: - -```text -build// -├─ web/ -│ ├─ index.html -│ ├─ assets/ -│ ├─ maintenance.html -│ └─ admin/ -├─ api-server -├─ spacetime_module.wasm -├─ scripts/ -│ ├─ spacetime-publish-prod.sh -│ ├─ database-export.sh -│ ├─ database-import.sh -│ ├─ maintenance-on.sh -│ ├─ maintenance-off.sh -│ └─ maintenance-status.sh -├─ deploy/ -│ ├─ systemd/ -│ │ ├─ spacetimedb.service -│ │ └─ genarrative-api.service -│ ├─ nginx/ -│ │ ├─ genarrative.conf -│ │ └─ snippets/maintenance.conf -│ └─ env/api-server.env.example -└─ README.md -``` - -不再生成旧产物: - -- `web-server.mjs` -- 旧的一体化 `start.sh` -- 旧的一体化 `stop.sh` - -## Jenkins 节点 - -Jenkins 可运行在 Windows 或其他机器上,但构建与发布动作使用 Linux agent。 - -### 开发/构建实例 - -- 节点名:`genarrative-dev` -- 标签:`genarrative-linux dev build` -- 用途:拉代码、安装依赖、构建主站、构建后台、构建 `api-server`、构建 SpacetimeDB wasm、归档产物。 - -### 生产/发布实例 - -- 节点名:`genarrative-prod` -- 标签:`genarrative-linux prod deploy` -- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 - -### SSH PEM 凭证 - -在 Jenkins 中使用 `SSH Username with private key` 类型添加 PEM 私钥: - -- `genarrative-dev-ssh-key`:开发/构建实例 SSH 凭证。 -- `genarrative-prod-ssh-key`:生产/发布实例 SSH 凭证。 - -推荐使用非 root 用户,例如 `jenkins`。该用户只通过 sudoers 获得必要命令权限,例如 `systemctl restart genarrative-api`、`nginx -t`、维护脚本、发布目录切换等。 - -## Jenkins 流水线 - -计划重新创建以下流水线: - -1. `Genarrative-Server-Provision` -2. `Genarrative-Web-Build` -3. `Genarrative-Web-Deploy` -4. `Genarrative-Api-Build` -5. `Genarrative-Api-Deploy` -6. `Genarrative-Stdb-Module-Build` -7. `Genarrative-Stdb-Module-Publish` -8. `Genarrative-Database-Export` -9. `Genarrative-Database-Import` -10. `Genarrative-Full-Build-And-Deploy` - -构建流水线运行在 `build && dev`,发布、导入导出和服务器配置流水线运行在 `deploy && prod`。 - -构建流水线支持参数 `PUBLISH_AFTER_BUILD`: - -- `false`:只构建并归档产物。 -- `true`:构建成功后触发对应发布流水线。 - -发布流水线必须从归档产物获取文件,不依赖构建 workspace 的本地状态。 - -## 流水线职责 - -### Genarrative-Server-Provision - -用于生产服务器一次性或低频配置: - -- 创建 `spacetimedb`、`genarrative` 等系统用户。 -- 创建 `/stdb`、`/opt/genarrative`、`/srv/genarrative`、`/etc/genarrative`、`/var/lib/genarrative/maintenance`。 -- 安装或更新 SpacetimeDB。 -- 安装 systemd unit。 -- 安装 Nginx 配置和维护模式 snippet。 -- 执行 `nginx -t`。 -- 启用并启动 `spacetimedb.service` 与 `genarrative-api.service`。 - -该流水线属于高风险操作,默认要求人工确认后执行。 - -### Web Build / Deploy - -构建: - -- 构建主站静态文件。 -- 构建后台前端,base path 为 `/admin/`。 -- 生成或复制 `maintenance.html`。 -- 归档 `web/` 产物。 - -发布: - -- 解包到 `/opt/genarrative/releases//web`。 -- 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 -- 执行 Nginx 配置测试和静态页面 smoke test。 -- 不进入维护模式。 - -### Api Build / Deploy - -构建: - -- 编译 Rust `api-server`。 -- 归档单一可执行文件和必要运行说明。 - -发布: - -- 进入维护模式。 -- 解包到 `/opt/genarrative/releases//api-server`。 -- 更新 `/opt/genarrative/current`。 -- 重启 `genarrative-api.service`。 -- 检查本机 `/healthz`。 -- 成功后解除维护模式。 -- 失败时保留维护模式并发邮件。 - -### Stdb Module Build / Publish - -构建: - -- 使用 `spacetime build` 构建 `spacetime_module.wasm`。 -- 归档 wasm 与发布脚本。 - -发布: - -- 进入维护模式。 -- 将 wasm 上传到生产实例。 -- 在生产实例本机执行 `spacetime publish -s local --bin-path spacetime_module.wasm `。 -- 成功后执行必要 smoke test。 -- 成功后解除维护模式。 -- 失败时保留维护模式并发邮件。 - -## 数据库导出与导入 - -### 导出 - -`Genarrative-Database-Export` 用于人工导出生产数据: - -- 运行在 `deploy && prod`。 -- 进入维护模式,避免导出期间继续写入。 -- 从本机 SpacetimeDB 导出指定数据库数据。 -- 产物归档到 Jenkins,并可额外保存到服务器备份目录。 -- 成功后解除维护模式。 -- 失败时保留维护模式并邮件通知。 - -### 导入 - -`Genarrative-Database-Import` 用于人工导入或恢复数据: - -- 运行在 `deploy && prod`。 -- 必须要求人工确认目标数据库、导入文件和是否覆盖。 -- 进入维护模式。 -- 导入前先生成一次安全备份。 -- 执行导入。 -- 执行数据校验和服务 smoke test。 -- 成功后解除维护模式。 -- 失败时保留维护模式并邮件通知。 - -数据库表结构变更必须同步检查并更新 `migration.rs`,不能只发布 wasm。 - -## 全量构建并发布 - -`Genarrative-Full-Build-And-Deploy` 顺序: - -1. 触发 `Genarrative-Web-Build`。 -2. 触发 `Genarrative-Api-Build`。 -3. 触发 `Genarrative-Stdb-Module-Build`。 -4. 触发 `Genarrative-Stdb-Module-Publish`。 -5. 触发 `Genarrative-Api-Deploy`。 -6. 触发 `Genarrative-Web-Deploy`。 -7. 执行生产 smoke test。 - -网站最后发布,避免后台或主站提前指向尚未完成发布的后端能力。 - -## 回滚 - -- 网站回滚:将 `/srv/genarrative/web` 或 `/opt/genarrative/current` 切回上一版本并 reload Nginx。 -- `api-server` 回滚:将 `/opt/genarrative/current` 切回上一版本并重启 `genarrative-api.service`。 -- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。 -- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。 - -## 待落地文件 - -后续工程落地时需要新增或改造: - -- `deploy/systemd/spacetimedb.service` -- `deploy/systemd/genarrative-api.service` -- `deploy/nginx/genarrative.conf` -- `deploy/nginx/snippets/maintenance.conf` -- `deploy/env/api-server.env.example` -- `scripts/deploy/maintenance-on.sh` -- `scripts/deploy/maintenance-off.sh` -- `scripts/deploy/maintenance-status.sh` -- 新 Jenkinsfile 或 Job DSL,用于上述 10 条流水线。 -- 更新旧部署文档,标记旧一体化脚本为废弃或迁移对象。 - -## 参考 - -- SpacetimeDB 官方自托管文档:https://spacetimedb.com/docs/how-to/deploy/self-hosting/