From 27d4d45af4437848784307a60df65cbbb97dc02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Fri, 11 Sep 2026 12:09:29 +0800 Subject: [PATCH] feat(deploy): run Docker application against external RDS --- README.md | 29 ++ deploy/dobotai-dingtalk/README.md | 310 +++++------------- deploy/dobotai-dingtalk/configure_env.py | 100 ++---- .../dobotai-dingtalk-bind-admin | 10 +- .../dobotai-dingtalk/dobotai-dingtalk-start | 76 +---- deploy/dobotai-dingtalk/install-docker.sh | 73 +++++ deploy/dobotai-dingtalk/rds-schema-init.sh | 31 ++ install-docker.sh | 40 +++ scripts/containerized_deployment_test.go | 133 +++----- 9 files changed, 351 insertions(+), 451 deletions(-) create mode 100644 deploy/dobotai-dingtalk/install-docker.sh create mode 100644 deploy/dobotai-dingtalk/rds-schema-init.sh create mode 100644 install-docker.sh diff --git a/README.md b/README.md index a3a7ae3..b67ef2b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,35 @@ DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份 目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。 +## 部署方式 + +| 方式 | 适用场景 | 数据库 | 入口 | +| --- | --- | --- | --- | +| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` | +| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` | + +### 源码部署 + +```bash +go run ./cmd/dobotai +curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz +``` + +源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。 + +### Docker + 阿里云RDS部署 + +克隆仓库后在根目录执行: + +```bash +sudo bash ./install-docker.sh +sudo dobotai-dingtalk-configure +``` + +第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。 + +完整安装、RDS检查、升级和回滚见 [Docker部署手册](deploy/dobotai-dingtalk/README.md)。 + ## 钉钉 Stream 使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。 diff --git a/deploy/dobotai-dingtalk/README.md b/deploy/dobotai-dingtalk/README.md index deaf9d9..0ae0397 100644 --- a/deploy/dobotai-dingtalk/README.md +++ b/deploy/dobotai-dingtalk/README.md @@ -1,280 +1,138 @@ -# DobotAI DingTalk 容器部署手册 +# DobotAI Docker部署(阿里云RDS) -> 适用范围:DobotAI 测试环境的容器化运行。容器和 Compose 服务名固定为 `dobotai-dingtalk`。 -> 本文不授权生产部署;生产必须使用已验收的同一镜像、独立备份和独立变更审批。 +## 定位 -## 1. 定位 - -DobotAI 是钉钉渠道的对话编排与交付层,不是知识库或文档解析平台。 - -它负责: - -- 接收钉钉 Stream 消息、富文本和图片; -- 将请求路由到 WeKnora Agent,并维护渠道会话; -- 通过钉钉 AI 卡片或 Markdown 流式交付回答; -- 处理 `/think`、`/setting` 等渠道命令; -- 保存 Turn、会话映射、覆盖判定、Support Case、反馈和审计数据; -- 对“真实检索过但没有引用”的回答创建未覆盖提醒; -- 将 `resource://` 图片按私有 OSS 规则安全外发。 - -它不负责: - -- WeKnora 文档、Chunk、向量、图谱、解析或模型运行; -- WeKnora Agent 的知识检索策略; -- 直接暴露 PostgreSQL、WeKnora 或对象存储到公网; -- 替代钉钉应用、机器人或群权限配置。 - -## 2. 架构 +DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。 ```text -钉钉用户 / 群 - │ DingTalk Stream - ▼ -┌──────────────────────────────────────┐ -│ dobotai-dingtalk 容器 │ -│ - 消息解析、会话编排、卡片流式交付 │ -│ - /think、/setting、首位管理员绑定 │ -│ - 覆盖判定、Support Case、审计 │ -└───────┬──────────────────┬───────────┘ - │ │ - │ HTTP Agent Chat │ PostgreSQL - ▼ ▼ - WeKnora 服务 DobotAI 独立数据库 - - Agent/检索 - Turn / 会话 / 覆盖判定 - - 文档/Chunk - Support Case / 反馈 / 设置 - - 模型与解析 +钉钉 Stream + ↓ +dobotai-dingtalk 容器 + ├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定 + └─ 既有WeKnora:Agent、检索、知识、模型 ``` -### 2.1 容器边界 - -| 资源 | 归属 | 是否由本容器创建/删除 | -| --- | --- | --- | -| `dobotai-dingtalk` | DobotAI应用进程 | 是,容器可重建 | -| DobotAI PostgreSQL | 独立数据服务 | 否,保留现有数据卷 | -| WeKnora | 独立知识服务 | 否 | -| `/var/lib/dobotai-test/secrets` | 宿主机密钥目录 | 仅挂载读写,不迁移、不输出 | -| `/etc/dobotai-test/dobotai.env` | 受保护运行配置 | Docker读取,脚本原子更新 | - -测试环境的 PostgreSQL、WeKnora 已通过宿主机回环地址提供服务,因此 Compose 使用 `network_mode: host`。这不是新开公网端口:DobotAI仍按 `DOBOTAI_LISTEN_ADDR` 监听,默认是 `127.0.0.1:8090`。 - -### 2.2 容器安全属性 - -Compose 固定启用: +禁止创建或使用: ```text -容器名:dobotai-dingtalk -网络:host(仅兼容现有测试宿主机回环依赖) -运行身份:与原 dobotai-test 服务用户相同的 UID:GID -根文件系统:只读 -临时写入:仅 /tmp tmpfs -额外权限:no-new-privileges -重启策略:unless-stopped +dobotai-postgres 容器 +dobotai-postgres-data 数据卷 +本地PostgreSQL作为DobotAI目标数据库 ``` -对应的关键 Compose 配置如下;不要为避免端口冲突额外添加 `ports:` 映射: - -```yaml -services: - dobotai-dingtalk: - container_name: dobotai-dingtalk - network_mode: host - read_only: true - restart: unless-stopped -``` - -## 3. 安装前检查 - -安装操作人员需要具备测试服务器的 `sudo` 和 Docker 权限。开始前确认: +## 架构与目录 ```text -1. 已构建或导入经审查的 DobotAI 镜像; -2. 现有 /etc/dobotai-test/dobotai.env 存在且权限为 0600; -3. 现有密钥目录 /var/lib/dobotai-test/secrets 存在; -4. DobotAI PostgreSQL 与 WeKnora 已健康; -5. 已记录当前原生服务二进制SHA和运行状态; -6. 如本版本包含 SQL 迁移,已先完成逻辑备份与迁移演练。 +宿主机 +├── dobotai-dingtalk 唯一应用容器,host网络 +├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本 +├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置 +├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore +└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷 + +阿里云RDS +└── DobotAI专用PostgreSQL数据库 ``` -不要执行: +应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。 -```text -docker compose down -v -docker volume rm / prune -删除 PostgreSQL / WeKnora 数据卷 -将密钥、数据库连接串或 API Key 写入命令行、日志或 Git -``` +## 安装 -## 4. 构建和导入镜像 - -在已审查且工作树干净的源码目录构建。示例中的值均为占位符: +克隆完整仓库后,只执行: ```bash -docker build \ - --build-arg VERSION=test- \ - --build-arg COMMIT_SHA=<完整提交SHA> \ - --build-arg BUILD_TIME= \ - -t dobotai-dingtalk:test- . +sudo bash ./install-docker.sh ``` -跨主机传输时先保存镜像归档并记录 SHA-256;测试服务器导入后必须复核归档哈希和镜像标签。生产不得在目标机重新编译,应使用已测试的同一镜像归档或不可变镜像摘要。 - -## 5. 安装容器资产(不切换服务) - -将本目录、候选镜像和已核验的归档放到测试服务器的受控发布目录。确认镜像已经 `docker load` 后执行: - -```bash -sudo DOBOTAI_IMAGE=dobotai-dingtalk:test- \ - bash ./install-test.sh -``` - -该脚本会: +根脚本自动: ```text -1. 检查候选镜像、现有 env 与密钥目录; -2. 备份原生 DobotAI 二进制和 systemd Unit,并写入 SHA-256; -3. 安装 Compose、配置器、启动器和绑定脚本; -4. 生成权限 0600 的 /opt/dobotai-test/container/runtime.env; -5. 保持原生 dobotai-test.service 继续运行。 +检查或安装Docker Engine和Docker Compose v2(APT系统) +以当前Git提交构建 dobotai-dingtalk:local- +创建 /opt/dobotai/{container,config,secrets,backups} +复制应用Compose、RDS检查器和21个SQL迁移 +生成权限0600的应用环境模板 +安装三条短命令 ``` -它不会读取或打印环境变量值、不会修改数据库、不会启动容器。 +它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。 -## 6. 配置并启动 +## 配置、RDS检查与启动 -日常只需要执行: +安装后执行: ```bash sudo dobotai-dingtalk-configure ``` -该命令交互更新 `/etc/dobotai-test/dobotai.env`。每项都会显示用途和脱敏格式示例;已配置的值永不回显,直接回车表示保留。 +配置器要求填写阿里云RDS连接串、WeKnora和钉钉凭证;密钥不回显,环境文件原子写入且权限0600。 -| 环境变量 | 用途 | 格式示例 | +| 配置 | 示例 | 作用 | | --- | --- | --- | -| `DATABASE_URL` | DobotAI独立PostgreSQL连接串,不得填写WeKnora数据库 | `postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable` | -| `WEKNORA_BASE_URL` | 测试WeKnora地址 | `http://127.0.0.1:8080` | -| `WEKNORA_API_KEY` | DobotAI使用的WeKnora API密钥 | `<你的WeKnora API Key>` | -| `DOBOTAI_INGRESS_TOKEN` | `/v1/qa`调用令牌 | `<随机长字符串>` | -| `DOBOTAI_TENANT_KEY` | 与既有DobotAI数据一致的租户键 | `dobotai-test` | -| `DOBOTAI_AGENT_BINDINGS` | 钉钉会话到WeKnora Agent的映射 | `{"dingtalk:*":"你的Agent ID"}` | -| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` | -| `DINGTALK_CLIENT_SECRET` | 钉钉应用Client Secret/AppSecret | `<你的钉钉 AppSecret>` | +| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL | +| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora | +| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key | +| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey | +| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret | -保存成功后,配置器自动调用启动器: +保存后脚本自动执行以下RDS门禁: ```text -停止原生 dobotai-test.service -→ 启动 dobotai-dingtalk -→ 检查 http://127.0.0.1:8090/healthz -→ 健康成功后禁用原生服务 +1. 使用RDS连接串执行 pg_isready +2. 检查DobotAI核心表 +3. 数据库没有任何public表:按000001~000021初始化 +4. 所有核心表存在:判定正常,不执行任何DDL +5. 部分表、缺表或非空异常库:拒绝初始化和启动 +6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz ``` -首次容器启动失败会恢复原生服务;容器版本升级失败会恢复上一容器镜像。 +这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。 -## 7. 首位管理员绑定 +## 日常命令 -仅当该租户不存在有效管理员时执行: +```bash +# 重新检查RDS并启动/升级应用 +sudo dobotai-dingtalk-start + +# 交互更新RDS、WeKnora、钉钉配置后启动 +sudo dobotai-dingtalk-configure + +# 仅首位管理员且无有效管理员时使用 +sudo dobotai-dingtalk-bind-admin + +# 健康与版本 +curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz +curl --noproxy '*' -fsS http://127.0.0.1:8090/version +``` + +## 升级与回滚 + +应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。 + +RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。 + +## 首位管理员 + +应用和钉钉Stream运行后: ```bash sudo dobotai-dingtalk-bind-admin ``` -终端会显示一个10分钟有效的随机六码。候选管理员必须私聊钉钉机器人发送: +终端会显示10分钟有效的六码;候选管理员私聊机器人: ```text /setting <六码绑定码> ``` -验证成功后,终端自动写入 `super_admin/active` 并退出。已有有效管理员时命令拒绝执行,防止借由该入口追加管理员或意外提权。 +已有有效管理员时绑定命令会拒绝,避免提权。 -## 8. 覆盖判定与未覆盖提醒 - -覆盖判定基于真实的 `knowledge_searched` 工具事件和结构化引用,不按关键词猜测。 - -| 场景 | 覆盖结果 | 是否创建 Support Case | -| --- | --- | --- | -| 有引用 | `covered_has_references` | 否 | -| 实际检索过但无引用 | `uncovered_no_references` | 是 | -| 明确无权威知识/无答案 | `uncovered_explicit_no_answer` | 是 | -| 图片未检索、纯视觉识别 | `covered_visual_recognition` | 否 | -| 文本未检索、闲聊或普通非知识响应 | `covered_non_knowledge_response` | 否 | - -因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会产生未覆盖提醒;真正检索过但没有权威引用的问题仍会进入人工支持流程。 - -## 9. 数据库迁移门禁 - -DobotAI进程只连接数据库,**不会自动发现或执行 SQL 迁移**。包含新迁移的版本必须在容器替换前由发布流程显式完成: +## 不执行的操作 ```text -1. 对 DobotAI PostgreSQL 做逻辑备份; -2. 使用 pg_restore --list 验证备份非空; -3. 在事务中执行已审查的 .up.sql; -4. 向 dobotai_schema_migrations 写入对应 version/name/applied_at; -5. 查询约束、列或索引确认生效; -6. 再替换容器镜像并进行健康与业务验收。 +不创建本地PostgreSQL容器或数据卷 +不迁移、删除或覆盖现有本地 dobotai-test-postgres +不创建、删除或重置阿里云RDS数据库 +不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志 +不重建WeKnora、Neo4j、Redis、Elasticsearch ``` - -`000021_coverage_non_knowledge_response_reason` 是一个示例:它只扩展 `coverage_decisions.reason_code` 的 CHECK 约束,不回写历史Turn、覆盖判定或Support Case。 - -## 10. 日常检查 - -```bash -# 健康检查 -curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz - -# 运行版本 -curl --noproxy '*' -fsS http://127.0.0.1:8090/version - -# 容器状态与重启次数 -sudo docker inspect -f '{{.Config.Image}}|{{.State.Running}}|{{.RestartCount}}' dobotai-dingtalk - -# 最近日志(不要复制含敏感内容的原始上下文) -sudo docker logs --since 10m dobotai-dingtalk -``` - -验收至少应同时满足: - -```text -容器 running,重启次数为0; -/healthz 返回 ok; -/version 的 commit_sha 与发布记录一致; -钉钉 Stream 仅启动一个消费者; -旧 dobotai-test.service 为 inactive + disabled; -目标业务路径已由真实钉钉消息验证。 -``` - -## 11. 升级与回滚 - -### 升级 - -```text -构建/导入经审查的新镜像 -→ 备份DobotAI PostgreSQL -→ 应用并记录必要迁移 -→ 将 runtime.env 中的 DOBOTAI_IMAGE 更新为新标签 -→ 执行 sudo dobotai-dingtalk-start -→ 健康、版本、日志、钉钉真实消息验收 -``` - -注意拼写:数据库和应用名称是 `DobotAI`;上述流程中的镜像名为 `dobotai-dingtalk`。 - -### 自动回滚 - -启动器会保存替换前的容器镜像: - -```text -首次容器化失败:停止候选容器,恢复原生服务; -后续容器升级失败:停止候选容器,恢复上一容器镜像。 -``` - -数据库迁移不能仅靠容器回滚撤销。只有经过审查且已验证的 `.down.sql` 才可用于数据库回滚;任何数据恢复都必须先确认备份可用。 - -## 12. 明确非目标 - -```text -不容器化或重建 WeKnora; -不容器化或删除 DobotAI PostgreSQL; -不重新启用已删除的网页管理面; -不在生产环境复用测试凭据、测试镜像或未验收的迁移; -不把密钥、Token、连接串、钉钉凭据写入 Git、命令行或日志。 -``` \ No newline at end of file diff --git a/deploy/dobotai-dingtalk/configure_env.py b/deploy/dobotai-dingtalk/configure_env.py index 4ec34e8..853f9b3 100644 --- a/deploy/dobotai-dingtalk/configure_env.py +++ b/deploy/dobotai-dingtalk/configure_env.py @@ -3,81 +3,53 @@ import argparse import getpass import os from pathlib import Path +import subprocess import sys FIELDS = ( - ("DATABASE_URL", "DobotAI PostgreSQL 连接串", "DobotAI独立数据库,不得填写WeKnora数据库。", "postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable", True), - ("WEKNORA_BASE_URL", "WeKnora 地址", "容器通过宿主网络访问测试WeKnora。", "http://127.0.0.1:8080", False), - ("WEKNORA_API_KEY", "WeKnora API Key", "在WeKnora中为DobotAI创建的API密钥。", "<你的WeKnora API Key>", True), - ("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True), - ("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "必须与现有DobotAI数据和Agent绑定使用同一个租户键。", "dobotai-test", False), - ("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话匹配到WeKnora Agent的映射。", '{"dingtalk:*":"你的Agent ID"}', False), - ("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用的Client ID/AppKey。", "dingxxxxxxxx", False), - ("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True), + ("DATABASE_URL", "阿里云RDS PostgreSQL连接串", "已有DobotAI专用阿里云RDS数据库;脚本只检查表,空库才初始化。", "postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require", True), + ("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False), + ("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True), + ("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用/v1/qa的独立令牌。", "<随机长字符串>", True), + ("DOBOTAI_TENANT_KEY", "DobotAI租户键", "必须与现有DobotAI数据一致。", "dobotai", False), + ("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话到WeKnora Agent映射。", '{"dingtalk:*":"你的Agent ID"}', False), + ("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False), + ("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True), ) - -def parse_env(path: Path): - lines = path.read_text(encoding="utf-8").splitlines() - values = {} +def parse_env(path): + lines = path.read_text(encoding="utf-8").splitlines(); values = {} for line in lines: if line and not line.lstrip().startswith("#") and "=" in line: - key, value = line.split("=", 1) - values[key.strip()] = value + key, value = line.split("=", 1); values[key.strip()] = value return lines, values - -def prompt(label: str, description: str, example: str, secret: bool, configured: bool) -> str: - suffix = "(已配置,直接回车保留)" if configured else "(必填)" - print(f"\n{label}\n用途:{description}\n格式示例:{example}") - if secret: - return getpass.getpass(f"{label}{suffix}: ").strip() - return input(f"{label}{suffix}: ").strip() - - -def write_env(path: Path, lines, values): - rendered = [] - written = set() +def write_env(path, lines, values): + rendered=[]; written=set() for line in lines: if line and not line.lstrip().startswith("#") and "=" in line: - key = line.split("=", 1)[0].strip() - if key in values: - rendered.append(f"{key}={values[key]}") - written.add(key) - continue + key=line.split("=",1)[0].strip() + if key in values: rendered.append(f"{key}={values[key]}"); written.add(key); continue rendered.append(line) - for key, value in values.items(): - if key not in written: - rendered.append(f"{key}={value}") - temporary = path.with_name(path.name + ".new") - temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8") - os.chmod(temporary,0o600) - os.replace(temporary, path) - + rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written) + temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path) def main(): - parser = argparse.ArgumentParser(description="配置 DobotAI 容器运行环境") - parser.add_argument("--env-file", required=True) - parser.add_argument("--start-script", required=True) - args = parser.parse_args() - - env_file = Path(args.env_file) - if not env_file.is_file(): - raise SystemExit("DobotAI 环境文件不存在,请先完成部署。") - lines, values = parse_env(env_file) - for key, label, description, example, secret in FIELDS: - entered = prompt(label, description, example, secret, bool(values.get(key, "").strip())) - if entered: - values[key] = entered - missing = [label for key, label, _, _, _ in FIELDS if not values.get(key, "").strip()] - if missing: - raise SystemExit("以下必填项未配置:" + "、".join(missing)) - write_env(env_file, lines, values) - os.execv(args.start_script, [args.start_script]) - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - sys.exit(130) + parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证") + parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True) + args=parser.parse_args(); path=Path(args.env_file) + if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。") + lines,values=parse_env(path) + for key,label,description,example,secret in FIELDS: + suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)" + print(f"\n{label}\n用途:{description}\n格式示例:{example}") + entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").strip() + if entered: values[key]=entered + missing=[label for key,label,*_ in FIELDS if not values.get(key,"").strip()] + if missing: raise SystemExit("以下必填项未配置:"+"、".join(missing)) + write_env(path,lines,values) + subprocess.run([args.schema_script], check=True) + os.execv(args.start_script,[args.start_script]) +if __name__=="__main__": + try: main() + except KeyboardInterrupt: sys.exit(130) diff --git a/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin b/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin index 5f42a7a..925b21a 100644 --- a/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin +++ b/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin @@ -1,10 +1,6 @@ #!/usr/bin/env bash set -euo pipefail - -container="dobotai-dingtalk" -if [[ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null || true)" != "true" ]]; then - echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2 - exit 1 -fi - +home="${DOBOTAI_HOME:-/opt/dobotai}" +container_dir="$home/container" +if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then echo "DobotAI容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2; exit 1; fi exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin diff --git a/deploy/dobotai-dingtalk/dobotai-dingtalk-start b/deploy/dobotai-dingtalk/dobotai-dingtalk-start index 42c3bdd..f992517 100644 --- a/deploy/dobotai-dingtalk/dobotai-dingtalk-start +++ b/deploy/dobotai-dingtalk/dobotai-dingtalk-start @@ -1,77 +1,23 @@ #!/usr/bin/env bash set -euo pipefail - -container_dir="/opt/dobotai-test/container" +home="${DOBOTAI_HOME:-/opt/dobotai}" +container_dir="$home/container" compose_file="$container_dir/compose.yaml" runtime_env="$container_dir/runtime.env" -legacy_service="dobotai-test.service" health_url="http://127.0.0.1:8090/healthz" - -if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then - echo "容器运行文件未安装。请先部署 dobotai-dingtalk。" >&2 - exit 1 -fi - -legacy_was_active=false -if systemctl is-active --quiet "$legacy_service"; then - legacy_was_active=true - systemctl stop "$legacy_service" -fi - -previous_image="$(docker inspect --format '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)" - -set_runtime_image() { - local image="$1" - python3 - "$runtime_env" "$image" <<'PY' -from pathlib import Path -import os -import sys - -path = Path(sys.argv[1]) -image = sys.argv[2] -lines = path.read_text(encoding="utf-8").splitlines() -updated = False -rendered = [] -for line in lines: - if line.startswith("DOBOTAI_IMAGE="): - rendered.append("DOBOTAI_IMAGE=" + image) - updated = True - else: - rendered.append(line) -if not updated: - rendered.append("DOBOTAI_IMAGE=" + image) -temporary = path.with_name(path.name + ".new") -temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8") -os.chmod(temporary, 0o600) -os.replace(temporary, path) -PY -} - +[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; } +"$container_dir/rds-schema-init.sh" +compose=(docker compose --env-file "$runtime_env" -f "$compose_file") +previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)" rollback() { - docker compose --env-file "$runtime_env" -f "$compose_file" down --remove-orphans >/dev/null 2>&1 || true - if [[ -n "$previous_image" ]]; then - set_runtime_image "$previous_image" - docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate >/dev/null 2>&1 || true - elif [[ "$legacy_was_active" == "true" ]]; then - systemctl start "$legacy_service" || true - fi + "${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true + if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi } trap rollback ERR - -docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate +"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk for _ in $(seq 1 30); do - if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then - systemctl disable "$legacy_service" >/dev/null - trap - ERR - echo "DobotAI 容器已启动:dobotai-dingtalk" - exit 0 - fi + if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then trap - ERR; echo "DobotAI容器已启动:dobotai-dingtalk"; exit 0; fi sleep 1 done - -if [[ -n "$previous_image" ]]; then - echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像。" >&2 -else - echo "DobotAI 容器健康检查失败,已回滚到原生服务。" >&2 -fi +echo "DobotAI健康检查失败,已恢复上一应用容器(如存在)。" >&2 exit 1 diff --git a/deploy/dobotai-dingtalk/install-docker.sh b/deploy/dobotai-dingtalk/install-docker.sh new file mode 100644 index 0000000..7bea8cc --- /dev/null +++ b/deploy/dobotai-dingtalk/install-docker.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source_root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/../.." && pwd)}" +home="${DOBOTAI_HOME:-/opt/dobotai}" +container_dir="$home/container" +config_dir="$home/config" +secret_dir="$home/secrets" +backup_dir="$home/backups" +app_env="$config_dir/dobotai.env" +runtime_env="$container_dir/runtime.env" +image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}" + +[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; } +docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; } +install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations" +install -d -m 0700 -o 10001 -g 10001 "$secret_dir" +install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml" +install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py" +install -m 0750 "$script_dir/rds-schema-init.sh" "$container_dir/rds-schema-init.sh" +install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start" +install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin" +install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/" + +if [[ ! -f "$app_env" ]]; then + umask 077 + cat > "$app_env" <<'EOF' +DOBOTAI_LISTEN_ADDR=127.0.0.1:8090 +DATABASE_URL= +WEKNORA_BASE_URL=http://127.0.0.1:8080 +WEKNORA_API_KEY= +DOBOTAI_INGRESS_TOKEN= +DOBOTAI_ADMIN_TOKEN= +DOBOTAI_TENANT_KEY=dobotai +DOBOTAI_AGENT_BINDINGS= +DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer +DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning +DINGTALK_CLIENT_ID= +DINGTALK_CLIENT_SECRET= +EOF + chmod 0600 "$app_env" +fi +umask 077 +cat > "$runtime_env" < "$target" < /usr/local/sbin/dobotai-dingtalk-configure <&2; exit 1; } +temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT +python3 - "$app_env" "$temp_env" <<'PY' +from pathlib import Path +import os,sys +values={} +for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines(): + if line and not line.startswith('#') and '=' in line: + key,value=line.split('=',1); values[key]=value +url=values.get('DATABASE_URL','').strip() +if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。') +Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600) +PY +rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; } +rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' +sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; } +sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; } +state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at) +case "$state" in + normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;; + inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;; + empty) echo "阿里云RDS为空,开始初始化DobotAI表。";; + *) echo "无法判定阿里云RDS表状态。" >&2; exit 1;; +esac +for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql +echo "阿里云RDS初始化完成。" diff --git a/install-docker.sh b/install-docker.sh new file mode 100644 index 0000000..86a205f --- /dev/null +++ b/install-docker.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "请在仓库根目录执行:sudo bash ./install-docker.sh" >&2 + exit 1 +fi +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$repo_root" +[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || { echo "必须从完整DobotAI源码仓库根目录运行。" >&2; exit 1; } +command -v git >/dev/null || { echo "未找到git。" >&2; exit 1; } +git rev-parse --is-inside-work-tree >/dev/null || { echo "请从git clone得到的仓库中执行。" >&2; exit 1; } + +install_docker_if_missing() { + if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return; fi + command -v apt-get >/dev/null || { echo "请先安装Docker Engine和Docker Compose v2。" >&2; exit 1; } + export DEBIAN_FRONTEND=noninteractive + apt-get update + if apt-cache show docker-compose-v2 >/dev/null 2>&1; then + apt-get install -y docker.io docker-compose-v2 + elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then + apt-get install -y docker.io docker-compose-plugin + else + echo "APT源缺少Docker Compose v2插件。" >&2; exit 1 + fi + systemctl enable --now docker +} +install_docker_if_missing +docker compose version >/dev/null + +commit_sha="$(git rev-parse HEAD)" +commit_short="$(git rev-parse --short=12 HEAD)" +image="dobotai-dingtalk:local-${commit_short}" +echo "[1/2] 构建DobotAI应用镜像:$image" +docker build --build-arg "GOPROXY=https://goproxy.cn,direct" --build-arg "VERSION=local-${commit_short}" --build-arg "COMMIT_SHA=${commit_sha}" --build-arg "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root" + +echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化" +export DOBOTAI_IMAGE="$image" +export DOBOTAI_SOURCE_ROOT="$repo_root" +exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh" diff --git a/scripts/containerized_deployment_test.go b/scripts/containerized_deployment_test.go index 7f9a5ac..03d60cf 100644 --- a/scripts/containerized_deployment_test.go +++ b/scripts/containerized_deployment_test.go @@ -6,112 +6,67 @@ import ( "testing" ) -func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testing.T) { +func TestDockerDeploymentContainsOnlyDobotAIApplication(t *testing.T) { compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml") if err != nil { t.Fatal(err) } - for _, required := range []string{ - "dobotai-dingtalk:", - "container_name: dobotai-dingtalk", - "network_mode: host", - "env_file:", - "read_only: true", - "/var/lib/dobotai/secrets", - } { - if !strings.Contains(string(compose), required) { + source := string(compose) + for _, required := range []string{"dobotai-dingtalk:", "container_name: dobotai-dingtalk", "network_mode: host", "env_file:", "read_only: true"} { + if !strings.Contains(source, required) { t.Errorf("compose missing %q", required) } } - - configure, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-configure") - if err != nil { - t.Fatal(err) - } - for _, required := range []string{ - "configure_env.py", - "/etc/dobotai-test/dobotai.env", - "dobotai-dingtalk-start", - } { - if !strings.Contains(string(configure), required) { - t.Errorf("configure command missing %q", required) - } - } - - bind, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin") - if err != nil { - t.Fatal(err) - } - for _, required := range []string{ - "docker exec -it dobotai-dingtalk", - "/usr/local/bin/dobotai bootstrap-admin", - } { - if !strings.Contains(string(bind), required) { - t.Errorf("bind command missing %q", required) + for _, forbidden := range []string{"dobotai-postgres", "postgres-data", "name: dobotai-network"} { + if strings.Contains(source, forbidden) { + t.Errorf("compose must not manage a local database resource %q", forbidden) } } } -func TestDobotAIDingTalkCredentialConfigurationNeverPassesSecretsOnCommandLine(t *testing.T) { +func TestRootInstallerBuildsImageWithoutOperatorImageArgument(t *testing.T) { + data, err := os.ReadFile("../install-docker.sh") + if err != nil { + t.Fatal(err) + } + source := string(data) + for _, required := range []string{"docker build", "dobotai-dingtalk:local-", "install_docker_if_missing", "DOBOTAI_SOURCE_ROOT", "deploy/dobotai-dingtalk/install-docker.sh"} { + if !strings.Contains(source, required) { + t.Errorf("root installer missing %q", required) + } + } + if strings.Contains(source, "${DOBOTAI_IMAGE") { + t.Fatal("root installer must not require DOBOTAI_IMAGE") + } +} + +func TestRDSInitializerOnlyInitializesEmptySchemaAndRejectsInconsistentSchema(t *testing.T) { + data, err := os.ReadFile("../deploy/dobotai-dingtalk/rds-schema-init.sh") + if err != nil { + t.Fatal(err) + } + source := string(data) + for _, required := range []string{"pg_isready", "to_regclass('public.turns')", "empty", "normal", "inconsistent", "*.up.sql", "DATABASE_URL", "postgres:16-alpine"} { + if !strings.Contains(source, required) { + t.Errorf("RDS initializer missing %q", required) + } + } + for _, forbidden := range []string{"docker compose", "dobotai-postgres", "pg_dump -Fc", "DROP DATABASE", "DROP SCHEMA"} { + if strings.Contains(source, forbidden) { + t.Errorf("RDS initializer must not manage or destroy databases: %q", forbidden) + } + } +} + +func TestRDSCredentialConfiguratorPromptsForExternalDatabase(t *testing.T) { data, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py") if err != nil { t.Fatal(err) } source := string(data) - for _, required := range []string{"getpass.getpass", "os.replace", "0o600", "DINGTALK_CLIENT_SECRET"} { + for _, required := range []string{"DATABASE_URL", "阿里云RDS", "getpass.getpass", "os.replace", "0o600"} { if !strings.Contains(source, required) { - t.Errorf("credential configurator missing %q", required) - } - } - for _, forbidden := range []string{"subprocess.run([", "-v", "DINGTALK_CLIENT_SECRET="} { - if strings.Contains(source, forbidden) { - t.Errorf("credential configurator must not expose secret through process arguments: %q", forbidden) - } - } -} - -func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExamples(t *testing.T) { - configurator, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py") - if err != nil { - t.Fatal(err) - } - documentation, err := os.ReadFile("../deploy/dobotai-dingtalk/README.md") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(configurator), "格式示例:{example}") || !strings.Contains(string(documentation), "格式示例") { - t.Fatal("configuration guidance must label every example") - } - for _, example := range []string{ - "postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable", - "http://127.0.0.1:8080", - `{"dingtalk:*":"你的Agent ID"}`, - "DINGTALK_CLIENT_SECRET", - } { - if !strings.Contains(string(configurator), example) { - t.Errorf("configurator missing redacted example %q", example) - } - if !strings.Contains(string(documentation), example) { - t.Errorf("documentation missing redacted example %q", example) - } - } -} - -func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) { - data, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-start") - if err != nil { - t.Fatal(err) - } - source := string(data) - for _, required := range []string{ - "previous_image=", - "set_runtime_image", - "docker inspect --format", - "set_runtime_image \"$previous_image\"", - "docker compose --env-file \"$runtime_env\" -f \"$compose_file\" up -d --no-build --force-recreate", - } { - if !strings.Contains(source, required) { - t.Errorf("start script missing container upgrade rollback %q", required) + t.Errorf("RDS configurator missing %q", required) } } }