commit 4ada03183aed58fdbad8b8291dd66e2982a1a36c Author: 徐勤民 Date: Mon May 18 19:49:31 2026 +0800 chore: scaffold private deployment repository diff --git a/.deploy-state/current.json b/.deploy-state/current.json new file mode 100644 index 0000000..37ba81a --- /dev/null +++ b/.deploy-state/current.json @@ -0,0 +1,14 @@ +{ + "privateVersion": "2026.05.18-private.1", + "profiles": ["base"], + "mysql": {"mode": "external", "status": "UNKNOWN"}, + "redis": {"mode": "external", "status": "UNKNOWN"}, + "services": { + "file": true, + "im": false, + "push": false, + "update": false, + "license": false + }, + "lastHealthcheck": null +} diff --git a/.deploy-state/history.log b/.deploy-state/history.log new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.deploy-state/history.log @@ -0,0 +1 @@ + diff --git a/.deploy-state/last-healthcheck.json b/.deploy-state/last-healthcheck.json new file mode 100644 index 0000000..e155738 --- /dev/null +++ b/.deploy-state/last-healthcheck.json @@ -0,0 +1,5 @@ +{ + "status": "UNKNOWN", + "checks": [] +} + diff --git a/.deploy-state/progress.md b/.deploy-state/progress.md new file mode 100644 index 0000000..240be91 --- /dev/null +++ b/.deploy-state/progress.md @@ -0,0 +1,9 @@ +# Deployment Progress + +| Time | Step | Status | Notes | +|------|------|--------|-------| + +| 2026-05-18T19:48:27+0800 | configure | STARTED | rendering initial config | +| 2026-05-18T19:48:27+0800 | render-config | STARTED | rendering runtime files | +| 2026-05-18T19:48:27+0800 | render-config | DONE | runtime files rendered | +| 2026-05-18T19:48:27+0800 | configure | DONE | config ready | diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ee15d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +PRIVATE_VERSION=2026.05.18-private.1 +REGISTRY=registry.example.com/xuqm +IMAGE_TAG=2026.05.18-private.1 + +COMPOSE_PROFILES=base + +ENABLE_FILE=true +ENABLE_IM=false +ENABLE_PUSH=false +ENABLE_UPDATE=false +ENABLE_LICENSE=false + +MYSQL_MODE=external +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_DATABASE=xuqm_private +MYSQL_USERNAME=xuqm +MYSQL_PASSWORD=change-me +MYSQL_ROOT_PASSWORD= +MYSQL_DATA_DIR=./data/mysql + +REDIS_MODE=external +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=change-me +REDIS_DATABASE=0 +REDIS_DATA_DIR=./data/redis + +CONSOLE_DOMAIN=https://console.customer.com +OPS_DOMAIN=https://ops.customer.com +DOCS_DOMAIN=https://docs.customer.com +FILE_DOMAIN=https://file.customer.com +IM_DOMAIN=https://im.customer.com +UPDATE_DOMAIN=https://update.customer.com +LICENSE_DOMAIN=https://license.customer.com +PUSH_DOMAIN=https://push.customer.com + +TENANT_BOOTSTRAP_EMAIL=admin@customer.com +TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login +TENANT_BOOTSTRAP_APP_KEY=ak_private_default + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81367c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.env +config/secrets.env +logs/*.log +data/mysql/* +data/redis/* +data/uploads/* +data/update/* +data/backups/* +!data/mysql/.gitkeep +!data/redis/.gitkeep +!data/uploads/.gitkeep +!data/update/.gitkeep +!data/backups/.gitkeep +dist/ +*.tar.gz +*.bak +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 0000000..42dd02d --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# XuqmGroup Private Deploy + +私有化部署仓库只负责客户环境交付,不包含业务源码和 demo 前后端。 + +## 快速开始 + +```bash +./scripts/configure.sh +vim .env +vim config/secrets.env +./scripts/install.sh --profile base +./scripts/healthcheck.sh +``` + +生产部署前必须完成: + +- 配置镜像仓库 `REGISTRY` 和版本 `IMAGE_TAG`。 +- 选择 MySQL/Redis 模式:`external` 使用客户自备服务,`managed` 由脚本创建容器服务。 +- 配置控制台、文档站、文件、IM、Push、Update、License 域名。 +- 配置 SMTP、Push 厂商、应用市场发布凭据。 +- 确认证书和反向代理策略,默认 Nginx 配置只作为模板入口。 + +## 部署模式 + +MySQL、Redis 支持两种模式: + +- `external`:客户自备连接,脚本只校验连通性和权限。 +- `managed`:脚本新建服务,自动创建数据库、账号、密码和数据目录。 + +生产环境默认推荐 `external/external`。 + +托管模式示例: + +```bash +./scripts/install.sh --profile base --mysql-mode managed --redis-mode managed +``` + +外部模式示例: + +```bash +./scripts/install.sh --profile base --mysql-mode external --redis-mode external +``` + +## 可选服务 + +- `base`:基础控制台、运营平台、文档站、文件服务。 +- `im`:IM HTTP / WebSocket。 +- `push`:厂商推送。 +- `update`:版本管理、RN 热更新、应用市场自动发布。 +- `license`:License 校验。 + +后期启用: + +```bash +./scripts/enable-service.sh im +./scripts/enable-service.sh push +./scripts/enable-service.sh update +./scripts/enable-service.sh license +``` + +禁用服务只修改部署配置并停止对应容器,不删除数据: + +```bash +./scripts/disable-service.sh im +``` + +## 接手入口 + +- 实时部署进度:`.deploy-state/progress.md` +- 最近运行状态:`.deploy-state/current.json` +- 最近健康检查:`.deploy-state/last-healthcheck.json` +- 脚本审计日志:`logs/audit.log` +- 交付说明:`docs/runbook.md` +- 配置说明:`docs/configuration.md` +- 验收清单:`docs/acceptance-checklist.md` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..1ef8378 --- /dev/null +++ b/VERSION @@ -0,0 +1,2 @@ +2026.05.18-private.1 + diff --git a/config/docs/docs-runtime.json b/config/docs/docs-runtime.json new file mode 100644 index 0000000..589f777 --- /dev/null +++ b/config/docs/docs-runtime.json @@ -0,0 +1,20 @@ +{ + "deployment": "PRIVATE", + "privateVersion": "2026.05.18-private.1", + "domains": { + "console": "https://console.customer.com", + "docs": "https://docs.customer.com", + "file": "https://file.customer.com", + "im": "https://im.customer.com", + "update": "https://update.customer.com", + "license": "https://license.customer.com", + "push": "https://push.customer.com" + }, + "features": { + "file": true, + "im": false, + "push": false, + "update": false, + "license": false + } +} diff --git a/config/infra.env b/config/infra.env new file mode 100644 index 0000000..0512945 --- /dev/null +++ b/config/infra.env @@ -0,0 +1,6 @@ +MYSQL_MODE=external +MYSQL_DATA_DIR=./data/mysql +REDIS_MODE=external +REDIS_DATA_DIR=./data/redis +FILE_STORAGE_MODE=local + diff --git a/config/mail/smtp.env b/config/mail/smtp.env new file mode 100644 index 0000000..d2a0c24 --- /dev/null +++ b/config/mail/smtp.env @@ -0,0 +1,6 @@ +SMTP_HOST= +SMTP_PORT=465 +SMTP_USERNAME= +SMTP_TLS=true +SMTP_SSL=true + diff --git a/config/nginx/conf.d/xuqm.conf b/config/nginx/conf.d/xuqm.conf new file mode 100644 index 0000000..08f30ae --- /dev/null +++ b/config/nginx/conf.d/xuqm.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name _; + + location /health { + return 200 "ok\n"; + } + + location / { + proxy_pass http://tenant-web:80; + } +} + diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf new file mode 100644 index 0000000..21d08bd --- /dev/null +++ b/config/nginx/nginx.conf @@ -0,0 +1,12 @@ +events {} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + client_max_body_size 1024m; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + include /etc/nginx/conf.d/*.conf; +} + diff --git a/config/observability/logging.env b/config/observability/logging.env new file mode 100644 index 0000000..6ec24db --- /dev/null +++ b/config/observability/logging.env @@ -0,0 +1,4 @@ +LOG_FORMAT=json +LOG_RETENTION_DAYS=30 +LOG_COLLECTOR=none + diff --git a/config/observability/metrics.env b/config/observability/metrics.env new file mode 100644 index 0000000..a84097d --- /dev/null +++ b/config/observability/metrics.env @@ -0,0 +1,3 @@ +METRICS_ENABLED=false +PROMETHEUS_ENABLED=false + diff --git a/config/sdk/xuqm-private-sdk.json b/config/sdk/xuqm-private-sdk.json new file mode 100644 index 0000000..611697f --- /dev/null +++ b/config/sdk/xuqm-private-sdk.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "configVersion": "2026.05.18-private.1", + "deployment": "PRIVATE", + "appKey": "ak_private_default", + "controlBaseUrl": "https://console.customer.com", + "fileBaseUrl": "https://file.customer.com", + "imApiBaseUrl": "https://im.customer.com", + "imWsUrl": "wss://im.customer.com/ws/im", + "pushBaseUrl": "https://push.customer.com", + "updateBaseUrl": "https://update.customer.com", + "licenseBaseUrl": "https://license.customer.com", + "docsBaseUrl": "https://docs.customer.com", + "features": { + "file": true, + "im": false, + "push": false, + "update": false, + "license": false + }, + "connectTimeoutMs": 10000, + "readTimeoutMs": 30000, + "logLevel": "WARN" +} diff --git a/config/secrets.env.example b/config/secrets.env.example new file mode 100644 index 0000000..8a023de --- /dev/null +++ b/config/secrets.env.example @@ -0,0 +1,5 @@ +MYSQL_PASSWORD=change-me +MYSQL_ROOT_PASSWORD= +REDIS_PASSWORD=change-me +SMTP_PASSWORD= + diff --git a/config/tenant/bootstrap.env b/config/tenant/bootstrap.env new file mode 100644 index 0000000..32615d1 --- /dev/null +++ b/config/tenant/bootstrap.env @@ -0,0 +1,4 @@ +TENANT_BOOTSTRAP_EMAIL=admin@customer.com +TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login +TENANT_BOOTSTRAP_APP_KEY=ak_private_default + diff --git a/config/vendors/push.env b/config/vendors/push.env new file mode 100644 index 0000000..c7e7f05 --- /dev/null +++ b/config/vendors/push.env @@ -0,0 +1,7 @@ +HUAWEI_PUSH_ENABLED=false +MI_PUSH_ENABLED=false +OPPO_PUSH_ENABLED=false +VIVO_PUSH_ENABLED=false +HONOR_PUSH_ENABLED=false +APNS_ENABLED=false + diff --git a/config/vendors/store-submit.env b/config/vendors/store-submit.env new file mode 100644 index 0000000..688b1ce --- /dev/null +++ b/config/vendors/store-submit.env @@ -0,0 +1,6 @@ +HUAWEI_STORE_ENABLED=false +MI_STORE_ENABLED=false +OPPO_STORE_ENABLED=false +VIVO_STORE_ENABLED=false +HONOR_STORE_ENABLED=false + diff --git a/config/xuqm.env b/config/xuqm.env new file mode 100644 index 0000000..52e2cef --- /dev/null +++ b/config/xuqm.env @@ -0,0 +1,28 @@ +DEPLOYMENT_MODE=PRIVATE +TENANT_REGISTER_ENABLED=false +TENANT_BOOTSTRAP_ENABLED=true + +ENABLE_FILE=true +ENABLE_IM=false +ENABLE_PUSH=false +ENABLE_UPDATE=false +ENABLE_LICENSE=false + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_DATABASE=xuqm_private +MYSQL_USERNAME=xuqm + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_DATABASE=0 + +CONSOLE_DOMAIN=https://console.customer.com +OPS_DOMAIN=https://ops.customer.com +DOCS_DOMAIN=https://docs.customer.com +FILE_DOMAIN=https://file.customer.com +IM_DOMAIN=https://im.customer.com +UPDATE_DOMAIN=https://update.customer.com +LICENSE_DOMAIN=https://license.customer.com +PUSH_DOMAIN=https://push.customer.com + diff --git a/data/backups/.gitkeep b/data/backups/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/backups/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/mysql/.gitkeep b/data/mysql/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/mysql/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/redis/.gitkeep b/data/redis/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/redis/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/update/.gitkeep b/data/update/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/update/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/uploads/.gitkeep b/data/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml new file mode 100644 index 0000000..1c0b5e5 --- /dev/null +++ b/docker-compose.infra.yml @@ -0,0 +1,30 @@ +services: + mysql: + image: mysql:8.4 + profiles: ["infra-mysql"] + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USERNAME} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + TZ: Asia/Shanghai + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+08:00 + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - ./data/mysql:/var/lib/mysql + restart: unless-stopped + + redis: + image: redis:7.4-alpine + profiles: ["infra-redis"] + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - ./data/redis:/data + restart: unless-stopped + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0235a3b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + tenant-service: + image: ${REGISTRY}/tenant-service:${IMAGE_TAG} + profiles: ["base"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + - ./config/tenant/bootstrap.env + restart: unless-stopped + + file-service: + image: ${REGISTRY}/file-service:${IMAGE_TAG} + profiles: ["base"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + volumes: + - ./data/uploads:/data/uploads + restart: unless-stopped + + tenant-web: + image: ${REGISTRY}/tenant-web:${IMAGE_TAG} + profiles: ["base"] + restart: unless-stopped + + ops-web: + image: ${REGISTRY}/ops-web:${IMAGE_TAG} + profiles: ["base"] + restart: unless-stopped + + docs-site: + image: ${REGISTRY}/docs-site:${IMAGE_TAG} + profiles: ["base"] + volumes: + - ./config/docs/docs-runtime.json:/app/config/docs-runtime.json:ro + - ./config/sdk/xuqm-private-sdk.json:/app/config/xuqm-private-sdk.json:ro + restart: unless-stopped + + nginx: + image: nginx:1.27-alpine + profiles: ["base"] + ports: + - "80:80" + - "443:443" + volumes: + - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./config/nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + - tenant-service + - tenant-web + - ops-web + - docs-site + restart: unless-stopped + + im-service: + image: ${REGISTRY}/im-service:${IMAGE_TAG} + profiles: ["im"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + restart: unless-stopped + + push-service: + image: ${REGISTRY}/push-service:${IMAGE_TAG} + profiles: ["push"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + - ./config/vendors/push.env + restart: unless-stopped + + update-service: + image: ${REGISTRY}/update-service:${IMAGE_TAG} + profiles: ["update"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + - ./config/vendors/store-submit.env + volumes: + - ./data/update:/data/update + restart: unless-stopped + + license-service: + image: ${REGISTRY}/license-service:${IMAGE_TAG} + profiles: ["license"] + env_file: + - ./config/xuqm.env + - ./config/secrets.env + restart: unless-stopped + diff --git a/docs/acceptance-checklist.md b/docs/acceptance-checklist.md new file mode 100644 index 0000000..7a1310b --- /dev/null +++ b/docs/acceptance-checklist.md @@ -0,0 +1,36 @@ +# 验收清单 + +## 基础部署 + +- `./scripts/configure.sh` 可重复执行。 +- `./scripts/install.sh --profile base` 可完成部署。 +- `./scripts/healthcheck.sh` 输出 `PASS` 或明确错误码。 +- 文档站加载 `config/sdk/xuqm-private-sdk.json`。 +- 租户注册入口关闭。 +- 首次启动自动创建内置租户。 + +## 中间件 + +- `external` MySQL/Redis 不启动本地容器。 +- `managed` MySQL/Redis 自动启动并持久化数据。 +- 托管模式自动生成密码并写入 `config/secrets.env`。 + +## 可选服务 + +- `im` 可后期独立启用和禁用。 +- `push` 可后期独立启用和禁用。 +- `update` 可后期独立启用和禁用。 +- `license` 可后期独立启用和禁用。 +- 禁用任一可选服务时,基础服务可继续运行。 + +## 厂商能力 + +- Push 支持华为、小米、OPPO、vivo、荣耀。 +- 应用市场自动发布支持华为、小米、OPPO、vivo、荣耀。 +- 厂商凭据缺失时返回明确诊断,不影响基础服务启动。 + +## 公有化隔离 + +- 公有化域名 `dev.xuqinmin.com` 不写入私有化 SDK 配置。 +- 私有化改造不影响公有化配置和部署链路。 +- 公有化回归通过后才能发布私有化版本。 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ccf9500 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,56 @@ +# 配置说明 + +## `.env` + +部署入口配置,控制镜像版本、服务 profile、域名和 MySQL/Redis 模式。 + +关键字段: + +- `REGISTRY`:私有 Docker 镜像仓库。 +- `IMAGE_TAG`:本次部署镜像版本。 +- `COMPOSE_PROFILES`:启用的服务集合,例如 `base,im,push,update,license`。 +- `MYSQL_MODE`:`external` 或 `managed`。 +- `REDIS_MODE`:`external` 或 `managed`。 +- `ENABLE_IM`、`ENABLE_PUSH`、`ENABLE_UPDATE`、`ENABLE_LICENSE`:运行时功能开关。 + +## `config/secrets.env` + +敏感配置文件,不提交 Git。 + +关键字段: + +- `MYSQL_PASSWORD` +- `MYSQL_ROOT_PASSWORD` +- `REDIS_PASSWORD` +- `SMTP_PASSWORD` + +托管模式下,如果密码为空或为 `change-me`,脚本会自动生成并写回该文件。 + +## `config/xuqm.env` + +业务服务共享配置,包含私有化运行模式、单租户初始化、域名和基础中间件连接信息。 + +私有化必须保持: + +```env +DEPLOYMENT_MODE=PRIVATE +TENANT_REGISTER_ENABLED=false +TENANT_BOOTSTRAP_ENABLED=true +``` + +## `config/sdk/xuqm-private-sdk.json` + +私有化 SDK 初始化配置,由 `scripts/render-config.sh` 生成。 + +文档站和客户应用示例必须使用该文件,不再指向 `dev.xuqinmin.com` 公有化逻辑。 + +## `config/vendors` + +厂商能力配置: + +- `push.env`:华为、小米、OPPO、vivo、荣耀 Push 凭据。 +- `store-submit.env`:华为、小米、OPPO、vivo、荣耀应用市场自动发布凭据。 + +## `config/mail/smtp.env` + +邮件服务配置。生产环境必须使用客户提供的 SMTP 服务。 diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..11ac897 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,58 @@ +# 私有化部署运行手册 + +## 目标 + +本仓库用于客户私有环境交付,只编排已有 Docker 镜像,不包含业务源码、不构建 demo 服务。 + +## 标准流程 + +1. 执行 `./scripts/configure.sh` 生成 `.env` 和 `config/secrets.env`。 +2. 修改 `.env`:镜像仓库、镜像版本、域名、可选服务、MySQL/Redis 模式。 +3. 修改 `config/secrets.env`:数据库密码、Redis 密码、SMTP 密码等敏感配置。 +4. 修改 `config/mail/smtp.env`、`config/vendors/*.env`。 +5. 执行 `./scripts/install.sh --profile base` 部署基础服务。 +6. 按需执行 `./scripts/enable-service.sh im|push|update|license` 启用可选服务。 +7. 执行 `./scripts/healthcheck.sh` 并检查 `.deploy-state/last-healthcheck.json`。 + +## MySQL/Redis 模式 + +`external` 模式: + +- 客户提供连接地址、账号、密码、数据库名。 +- 部署脚本只使用连接配置,不启动新服务。 +- 生产默认使用该模式。 + +`managed` 模式: + +- 部署脚本通过 Docker Compose 启动 MySQL/Redis。 +- 密码为空或为 `change-me` 时自动生成并写入 `config/secrets.env`。 +- 数据目录写入 `data/mysql` 和 `data/redis`。 + +## 可选服务 + +`im`、`push`、`update`、`license` 均可不部署,后期独立启用。 + +启用命令: + +```bash +./scripts/enable-service.sh im +./scripts/install.sh --profile base,im +``` + +禁用命令: + +```bash +./scripts/disable-service.sh im +``` + +禁用服务不会删除数据,重新启用后继续使用原配置和数据目录。 + +## 接手规则 + +任何 agent 开始执行前必须先查看: + +- `.deploy-state/progress.md` +- `.deploy-state/current.json` +- `logs/audit.log` + +执行关键步骤后必须追加进度,确保中断后可继续。 diff --git a/image-manifest.json b/image-manifest.json new file mode 100644 index 0000000..5ba0891 --- /dev/null +++ b/image-manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "privateVersion": "2026.05.18-private.1", + "registry": "registry.example.com/xuqm", + "images": [ + {"name": "tenant-service", "required": true}, + {"name": "file-service", "required": true}, + {"name": "tenant-web", "required": true}, + {"name": "ops-web", "required": true}, + {"name": "docs-site", "required": true}, + {"name": "im-service", "required": false, "profile": "im"}, + {"name": "push-service", "required": false, "profile": "push"}, + {"name": "update-service", "required": false, "profile": "update"}, + {"name": "license-service", "required": false, "profile": "license"} + ] +} diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..c813355 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +audit "backup" "STARTED" "creating config backup" +progress "backup" "STARTED" "creating config backup" + +mkdir -p "$ROOT_DIR/data/backups" +tar --exclude='config/secrets.env' -czf "$ROOT_DIR/data/backups/config-$(date +%Y%m%d%H%M%S).tar.gz" \ + -C "$ROOT_DIR" VERSION .env config .deploy-state + +audit "backup" "DONE" "backup created" +progress "backup" "DONE" "backup created" + diff --git a/scripts/configure.sh b/scripts/configure.sh new file mode 100755 index 0000000..6fc9a08 --- /dev/null +++ b/scripts/configure.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +audit "configure" "STARTED" "rendering initial config" +progress "configure" "STARTED" "rendering initial config" + +if [ ! -f "$ROOT_DIR/.env" ]; then + cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env" +fi + +ensure_secret_file + +"$ROOT_DIR/scripts/render-config.sh" + +audit "configure" "DONE" "config ready" +progress "configure" "DONE" "config ready" diff --git a/scripts/disable-service.sh b/scripts/disable-service.sh new file mode 100755 index 0000000..71e2de9 --- /dev/null +++ b/scripts/disable-service.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +SERVICE="${1:-}" +[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required" "disable-service" + +if [ ! -f "$ROOT_DIR/.env" ]; then + cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env" +fi +load_env + +audit "disable-service" "STARTED" "$SERVICE" +progress "disable-service" "STARTED" "$SERVICE" + +case "$SERVICE" in + im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "false" ;; + push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "false" ;; + update) set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "false" ;; + license) set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "false" ;; + *) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE" "disable-service" ;; +esac + +set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$(remove_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")" +"$ROOT_DIR/scripts/render-config.sh" + +audit "disable-service" "DONE" "$SERVICE" +progress "disable-service" "DONE" "$SERVICE" diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..0d7b58a --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +audit "doctor" "STARTED" "collecting diagnostics" +progress "doctor" "STARTED" "collecting diagnostics" + +mkdir -p "$ROOT_DIR/dist" +tar --exclude='config/secrets.env' --exclude='data' --exclude='*.tar.gz' \ + -czf "$ROOT_DIR/dist/doctor-$(date +%Y%m%d%H%M%S).tar.gz" \ + -C "$ROOT_DIR" VERSION .deploy-state config logs README.md + +audit "doctor" "DONE" "diagnostics collected" +progress "doctor" "DONE" "diagnostics collected" + diff --git a/scripts/enable-service.sh b/scripts/enable-service.sh new file mode 100755 index 0000000..ac814c0 --- /dev/null +++ b/scripts/enable-service.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +SERVICE="${1:-}" +[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required" "enable-service" + +if [ ! -f "$ROOT_DIR/.env" ]; then + cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env" +fi +load_env + +audit "enable-service" "STARTED" "$SERVICE" +progress "enable-service" "STARTED" "$SERVICE" + +case "$SERVICE" in + im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "true" ;; + push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "true" ;; + update) set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "true" ;; + license) set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "true" ;; + *) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE" "enable-service" ;; +esac + +set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$(add_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")" +"$ROOT_DIR/scripts/render-config.sh" + +audit "enable-service" "DONE" "$SERVICE" +progress "enable-service" "DONE" "$SERVICE" diff --git a/scripts/export-offline-bundle.sh b/scripts/export-offline-bundle.sh new file mode 100755 index 0000000..2703438 --- /dev/null +++ b/scripts/export-offline-bundle.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" + +OUT_DIR="${1:-$ROOT_DIR/dist}" +mkdir -p "$OUT_DIR" + +audit "export-offline-bundle" "STARTED" "$OUT_DIR" +progress "export-offline-bundle" "STARTED" "$OUT_DIR" + +tar --exclude='data' --exclude='dist' --exclude='.git' -czf "$OUT_DIR/xuqm-private-$(cat "$ROOT_DIR/VERSION").tar.gz" -C "$ROOT_DIR" . + +audit "export-offline-bundle" "DONE" "$OUT_DIR" +progress "export-offline-bundle" "DONE" "$OUT_DIR" + diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..7ed8176 --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" +load_env + +audit "healthcheck" "STARTED" "running checks" +progress "healthcheck" "STARTED" "running checks" + +require_cmd docker + +STATUS="UP" +WARNINGS="[]" + +if ! docker ps >/dev/null 2>&1; then + STATUS="DOWN" +fi + +cat > "$ROOT_DIR/.deploy-state/last-healthcheck.json" < Docker Compose profiles, for example: base,im,push,update,license + --mysql-mode external or managed + --redis-mode external or managed + --offline-bundle Load images from an offline bundle before deployment + -h, --help Show help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --profile) + PROFILE="${2:-}" + shift 2 + ;; + --profile=*) + PROFILE="${1#--profile=}" + shift + ;; + --mysql-mode) + MYSQL_MODE="${2:-}" + set_env_value "$ROOT_DIR/.env" "MYSQL_MODE" "$MYSQL_MODE" + set_env_value "$ROOT_DIR/config/infra.env" "MYSQL_MODE" "$MYSQL_MODE" + shift 2 + ;; + --mysql-mode=*) + MYSQL_MODE="${1#--mysql-mode=}" + set_env_value "$ROOT_DIR/.env" "MYSQL_MODE" "$MYSQL_MODE" + set_env_value "$ROOT_DIR/config/infra.env" "MYSQL_MODE" "$MYSQL_MODE" + shift + ;; + --redis-mode) + REDIS_MODE="${2:-}" + set_env_value "$ROOT_DIR/.env" "REDIS_MODE" "$REDIS_MODE" + set_env_value "$ROOT_DIR/config/infra.env" "REDIS_MODE" "$REDIS_MODE" + shift 2 + ;; + --redis-mode=*) + REDIS_MODE="${1#--redis-mode=}" + set_env_value "$ROOT_DIR/.env" "REDIS_MODE" "$REDIS_MODE" + set_env_value "$ROOT_DIR/config/infra.env" "REDIS_MODE" "$REDIS_MODE" + shift + ;; + --offline-bundle) + OFFLINE_BUNDLE="${2:-}" + shift 2 + ;; + --offline-bundle=*) + OFFLINE_BUNDLE="${1#--offline-bundle=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail_json "XUQM_PRIVATE_4002" "unknown install option: $1" "install" + ;; + esac +done + +[ -n "$PROFILE" ] || fail_json "XUQM_PRIVATE_4003" "profile cannot be empty" "install" +set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$PROFILE" + +audit "install" "STARTED" "profile=$PROFILE" +progress "install" "STARTED" "profile=$PROFILE" + +require_cmd docker + +if [ -n "$OFFLINE_BUNDLE" ]; then + [ -f "$OFFLINE_BUNDLE" ] || fail_json "XUQM_PRIVATE_4004" "offline bundle not found: $OFFLINE_BUNDLE" "install" + docker load -i "$OFFLINE_BUNDLE" +fi + +if [ "${MYSQL_MODE:-external}" = "managed" ]; then + "$ROOT_DIR/scripts/install-mysql.sh" +fi + +if [ "${REDIS_MODE:-external}" = "managed" ]; then + "$ROOT_DIR/scripts/install-redis.sh" +fi + +"$ROOT_DIR/scripts/render-config.sh" + +COMPOSE_PROFILES="$PROFILE" compose up -d +"$ROOT_DIR/scripts/healthcheck.sh" + +audit "install" "DONE" "profile=$PROFILE" +progress "install" "DONE" "profile=$PROFILE" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..d92f41b --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +AUDIT_LOG="$ROOT_DIR/logs/audit.log" +PROGRESS_FILE="$ROOT_DIR/.deploy-state/progress.md" + +now() { + date +"%Y-%m-%dT%H:%M:%S%z" +} + +audit() { + local action="$1" + local status="$2" + local message="${3:-}" + mkdir -p "$(dirname "$AUDIT_LOG")" + printf '{"time":"%s","action":"%s","status":"%s","message":"%s"}\n' \ + "$(now)" "$action" "$status" "$message" >> "$AUDIT_LOG" +} + +progress() { + local step="$1" + local status="$2" + local notes="${3:-}" + mkdir -p "$(dirname "$PROGRESS_FILE")" + if [ ! -f "$PROGRESS_FILE" ]; then + printf '# Deployment Progress\n\n| Time | Step | Status | Notes |\n|------|------|--------|-------|\n' > "$PROGRESS_FILE" + fi + printf '| %s | %s | %s | %s |\n' "$(now)" "$step" "$status" "$notes" >> "$PROGRESS_FILE" +} + +fail_json() { + local code="$1" + local message="$2" + local step="${3:-unknown}" + printf '{"code":"%s","message":"%s","step":"%s","recoverable":true}\n' "$code" "$message" "$step" >&2 + audit "$step" "FAILED" "$message" + progress "$step" "FAILED" "$message" + exit 1 +} + +load_env() { + set -a + [ -f "$ROOT_DIR/.env" ] && . "$ROOT_DIR/.env" + [ -f "$ROOT_DIR/config/infra.env" ] && . "$ROOT_DIR/config/infra.env" + [ -f "$ROOT_DIR/config/xuqm.env" ] && . "$ROOT_DIR/config/xuqm.env" + [ -f "$ROOT_DIR/config/secrets.env" ] && . "$ROOT_DIR/config/secrets.env" + set +a +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail_json "XUQM_PRIVATE_4001" "missing required command: $1" "preflight" +} + +random_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 32 | tr -d '\n' + else + printf '%s_%s' "$(date +%s)" "$RANDOM" + fi +} + +ensure_secret_file() { + if [ ! -f "$ROOT_DIR/config/secrets.env" ]; then + cp "$ROOT_DIR/config/secrets.env.example" "$ROOT_DIR/config/secrets.env" + chmod 600 "$ROOT_DIR/config/secrets.env" || true + fi +} + +set_env_value() { + local file="$1" + local key="$2" + local value="$3" + mkdir -p "$(dirname "$file")" + touch "$file" + if grep -q "^${key}=" "$file"; then + sed -i.bak "s|^${key}=.*|${key}=${value}|" "$file" + rm -f "${file}.bak" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +ensure_env_value() { + local file="$1" + local key="$2" + local current="${3:-}" + local generated="$4" + if [ -z "$current" ] || [ "$current" = "change-me" ]; then + set_env_value "$file" "$key" "$generated" + printf '%s' "$generated" + else + printf '%s' "$current" + fi +} + +profile_contains() { + local profiles="$1" + local target="$2" + case ",${profiles}," in + *,"${target}",*) return 0 ;; + *) return 1 ;; + esac +} + +add_profile() { + local profiles="${1:-base}" + local target="$2" + if profile_contains "$profiles" "$target"; then + printf '%s' "$profiles" + else + printf '%s,%s' "$profiles" "$target" + fi +} + +remove_profile() { + local profiles="$1" + local target="$2" + local result="" + local item + IFS=',' read -r -a items <<< "$profiles" + for item in "${items[@]}"; do + [ "$item" = "$target" ] && continue + [ -z "$item" ] && continue + if [ -z "$result" ]; then + result="$item" + else + result="$result,$item" + fi + done + printf '%s' "${result:-base}" +} + +compose() { + docker compose --env-file "$ROOT_DIR/.env" -f "$ROOT_DIR/docker-compose.yml" -f "$ROOT_DIR/docker-compose.infra.yml" "$@" +} diff --git a/scripts/render-config.sh b/scripts/render-config.sh new file mode 100755 index 0000000..fea0435 --- /dev/null +++ b/scripts/render-config.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/lib.sh" +load_env + +strip_protocol() { + local value="$1" + value="${value#https://}" + value="${value#http://}" + printf '%s' "$value" +} + +ws_url() { + local value="$1" + local host + host="$(strip_protocol "$value")" + if [ -z "$host" ]; then + printf '' + else + printf 'wss://%s/ws/im' "$host" + fi +} + +audit "render-config" "STARTED" "rendering runtime files" +progress "render-config" "STARTED" "rendering runtime files" + +mkdir -p "$ROOT_DIR/config/docs" "$ROOT_DIR/config/sdk" + +cat > "$ROOT_DIR/config/docs/docs-runtime.json" < "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" < "$ROOT_DIR/.deploy-state/current.json" <