Compare commits

...

4 Commits

14 changed files with 696 additions and 314 deletions
+2 -1
View File
@@ -2,7 +2,8 @@ FROM golang:1.26.5-bookworm AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download ARG GOPROXY=https://goproxy.cn,direct
RUN GOPROXY=$GOPROXY go mod download
COPY . ./ COPY . ./
ARG VERSION=dev ARG VERSION=dev
+33
View File
@@ -76,6 +76,39 @@ DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。 目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
## 部署方式
| 方式 | 适用场景 | 入口 | 数据库与迁移 | 推荐程度 |
| --- | --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试、临时问题定位 | `go run ./cmd/dobotai` 或受控构建的二进制 | 操作人员准备独立PostgreSQL并显式执行迁移 | 仅开发 |
| Docker Compose | 测试、预发布、生产候选 | 仓库根目录`install-docker.sh` | 脚本构建镜像、创建`dobotai-postgres`和`dobotai-postgres-data`,并按序执行迁移 | 推荐 |
### 源码/原生部署
```bash
# 开发机:使用Git外的环境文件注入真实配置
go run ./cmd/dobotai
curl http://127.0.0.1:8090/healthz
```
源码方式不自动创建数据库、不管理密钥目录、不自动执行SQL迁移,也不适合作为长期测试或生产运行方式。
### Docker Compose 部署
```bash
# 克隆后进入仓库根目录执行:自动安装Docker/Compose(如缺失)、构建镜像、
# 准备目录、独立PostgreSQL、命名卷、env模板和迁移。
sudo bash ./install-docker.sh
# 配置WeKnora和钉钉后自动启动应用
sudo dobotai-dingtalk-configure
# 仅首次且没有有效管理员时执行
sudo dobotai-dingtalk-bind-admin
```
完整的安装、现有数据库迁移、备份、回滚和验收说明见 [Docker 部署手册](deploy/dobotai-dingtalk/README.md)。
## 钉钉 Stream ## 钉钉 Stream
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。 使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
+178 -196
View File
@@ -1,280 +1,262 @@
# DobotAI DingTalk 容器部署手册 # DobotAI DingTalk Docker 部署手册
> 适用范围:DobotAI 测试环境的容器化运行。容器和 Compose 服务名固定为 `dobotai-dingtalk`。 > 适用范围:DobotAI 的 Docker Compose 部署。容器名固定为 `dobotai-dingtalk`,数据库容器名固定为 `dobotai-postgres`。
> 本文不授权生产部署;生产必须使用已验收的同一镜像、独立备份和独立变更审批。 >
> 本文面向测试/预发布。生产必须使用已验收的同一镜像、独立备份和独立审批,禁止在生产重新编译。
## 1. 定位 ## 1. 定位
DobotAI 是钉钉渠道的对话编排与交付层,不是知识库或文档解析平台。 DobotAI 是钉钉渠道的对话编排与交付层,不是知识库、文档解析器或模型平台。
它负责: ```text
DobotAI 负责:
钉钉 Stream、富文本/图片、会话映射、WeKnora Agent 调用、卡片交付、
/think、/setting、覆盖判定、Support Case、反馈、渠道审计。
- 接收钉钉 Stream 消息、富文本和图片; DobotAI 不负责:
- 将请求路由到 WeKnora Agent,并维护渠道会话; WeKnora 文档/Chunk/向量/图谱/解析/模型;钉钉应用创建;知识库治理。
- 通过钉钉 AI 卡片或 Markdown 流式交付回答; ```
- 处理 `/think`、`/setting` 等渠道命令;
- 保存 Turn、会话映射、覆盖判定、Support Case、反馈和审计数据;
- 对“真实检索过但没有引用”的回答创建未覆盖提醒;
- 将 `resource://` 图片按私有 OSS 规则安全外发。
它不负责: ## 2. Docker 架构
- WeKnora 文档、Chunk、向量、图谱、解析或模型运行;
- WeKnora Agent 的知识检索策略;
- 直接暴露 PostgreSQL、WeKnora 或对象存储到公网;
- 替代钉钉应用、机器人或群权限配置。
## 2. 架构
```text ```text
钉钉用户/群 钉钉用户/群
│ DingTalk Stream │ DingTalk Stream(出站长连接)
▼ ▼
┌──────────────────────────────────────┐ ┌────────────────────────────────────────┐
│ dobotai-dingtalk 容器 │ │ dobotai-dingtalk │
│ - 消息解析、会话编排、卡片流式交付 │ │ 消息、会话、卡片、覆盖与支持流程 │
│ - /think、/setting、首位管理员绑定 │ │ 仅映射到宿主机 127.0.0.1:8090 │
│ - 覆盖判定、Support Case、审计 │ └───────┬──────────────────────┬───────────┘
└───────┬──────────────────┬───────────┘ │ Compose 网络 │ host.docker.internal
│ │
│ HTTP Agent Chat │ PostgreSQL
▼ ▼ ▼ ▼
WeKnora 服务 DobotAI 独立数据库 ┌────────────────────┐ 已有 WeKnora 服务
- Agent/检索 - Turn / 会话 / 覆盖判定 │ dobotai-postgres │ Agent / 检索 / 知识 / 模型
- 文档/Chunk - Support Case / 反馈 / 设置 │ dobotai 独立数据库 │
- 模型与解析 │ 命名卷持久化 │
└────────────────────┘
``` ```
### 2.1 容器边界 Compose 管理的资源:
| 资源 | 归属 | 是否由本容器创建/删除 | | 资源 | 名称 | 数据策略 |
| --- | --- | --- | | --- | --- | --- |
| `dobotai-dingtalk` | DobotAI应用进程 | 是,容器可重建 | | 应用容器 | `dobotai-dingtalk` | 可重建,不保存业务数据卷 |
| DobotAI PostgreSQL | 独立数据服务 | 否,保留现有数据卷 | | PostgreSQL容器 | `dobotai-postgres` | 可重建,数据在命名卷 |
| WeKnora | 独立知识服务 | 否 | | PostgreSQL命名卷 | `dobotai-postgres-data` | 持久数据,禁止常规删除 |
| `/var/lib/dobotai-test/secrets` | 宿主机密钥目录 | 仅挂载读写,不迁移、不输出 | | Compose网络 | `dobotai-network` | 应用与数据库内部通信 |
| `/etc/dobotai-test/dobotai.env` | 受保护运行配置 | Docker读取,脚本原子更新 | | 宿主机密钥目录 | `<DOBOTAI_HOME>/secrets` | bind mount,不进入Git |
测试环境的 PostgreSQL、WeKnora 已通过宿主机回环地址提供服务,因此 Compose 使用 `network_mode: host`。这不是新开公网端口:DobotAI仍按 `DOBOTAI_LISTEN_ADDR` 监听,默认是 `127.0.0.1:8090`。 关键Compose属性:
### 2.2 容器安全属性
Compose 固定启用:
```text
容器名:dobotai-dingtalk
网络:host(仅兼容现有测试宿主机回环依赖)
运行身份:与原 dobotai-test 服务用户相同的 UID:GID
根文件系统:只读
临时写入:仅 /tmp tmpfs
额外权限:no-new-privileges
重启策略:unless-stopped
```
对应的关键 Compose 配置如下;不要为避免端口冲突额外添加 `ports:` 映射:
```yaml ```yaml
services: services:
dobotai-postgres:
container_name: dobotai-postgres
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
dobotai-dingtalk: dobotai-dingtalk:
container_name: dobotai-dingtalk container_name: dobotai-dingtalk
network_mode: host depends_on:
read_only: true dobotai-postgres:
restart: unless-stopped condition: service_healthy
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
``` ```
## 3. 安装前检查 应用不使用 `network_mode: host`:它以`dobotai-postgres:5432`访问Compose数据库,以`http://host.docker.internal:8080`访问宿主机的WeKnora。
安装操作人员需要具备测试服务器的 `sudo` 和 Docker 权限。开始前确认: ## 3. 自动安装准备
`install-docker.sh`自动完成:
```text ```text
1. 已构建或导入经审查的 DobotAI 镜像; 检查 Docker、Docker Compose 和已导入的候选镜像
2. 现有 /etc/dobotai-test/dobotai.env 存在且权限为 0600; 创建 <DOBOTAI_HOME>/container、config、secrets、backups、migrations
3. 现有密钥目录 /var/lib/dobotai-test/secrets 存在; 生成权限0600的 config/postgres.env 与 config/dobotai.env
4. DobotAI PostgreSQL 与 WeKnora 已健康; 生成独立数据库密码、Ingress Token、Admin Token
5. 已记录当前原生服务二进制SHA和运行状态; 创建并启动 dobotai-postgres
6. 如本版本包含 SQL 迁移,已先完成逻辑备份与迁移演练。 等待 PostgreSQL healthcheck
按顺序应用 db/migrations/*.up.sql
在 dobotai_schema_migrations 记录版本、名称和时间
安装 configure / start / bind-admin 三条短命令
``` ```
不要执行: 它不会:
```text ```text
docker compose down -v 启动尚未配置的钉钉应用容器
docker volume rm / prune 覆盖已有配置或数据库数据
删除 PostgreSQL / WeKnora 数据卷 删除任何 Docker 卷
将密钥、数据库连接串或 API Key 写入命令行、日志或 Git 输出密码、API Key、Token 或连接串
``` ```
## 4. 构建和导入镜像 默认安装根目录:
在已审查且工作树干净的源码目录构建。示例中的值均为占位符: ```text
/opt/dobotai
├── container/ Compose、脚本、SQL迁移、运行参数
├── config/ dobotai.env、postgres.env(0600)
├── secrets/ DobotAI SecretStore(0700)
└── backups/ 逻辑备份和SHA清单
```
可以用`DOBOTAI_HOME=/绝对路径`改安装位置。测试环境建议使用独立目录,例如`/opt/dobotai-test-compose`。
## 4. 前置条件
```text
部署用户具有 sudo 和 Docker daemon 访问权限
宿主机 WeKnora 已健康,容器可经 host.docker.internal:8080 访问
```
Docker与Docker Compose缺失时由根安装脚本自动安装(当前支持APT系统)。不必手工构建、导入或命名镜像。
## 5. 全新安装
从源码仓库克隆后,进入仓库根目录执行唯一安装入口:
```bash ```bash
docker build \ sudo bash ./install-docker.sh
--build-arg VERSION=test-<commit-short> \
--build-arg COMMIT_SHA=<完整提交SHA> \
--build-arg BUILD_TIME=<UTC时间> \
-t dobotai-dingtalk:test-<commit-short> .
``` ```
跨主机传输时先保存镜像归档并记录 SHA-256;测试服务器导入后必须复核归档哈希和镜像标签。生产不得在目标机重新编译,应使用已测试的同一镜像归档或不可变镜像摘要。 脚本内部以当前Git提交构建固定内部镜像名:
## 5. 安装容器资产(不切换服务)
将本目录、候选镜像和已核验的归档放到测试服务器的受控发布目录。确认镜像已经 `docker load` 后执行:
```bash
sudo DOBOTAI_IMAGE=dobotai-dingtalk:test-<commit-short> \
bash ./install-test.sh
```
该脚本会:
```text ```text
1. 检查候选镜像、现有 env 与密钥目录; dobotai-dingtalk:local-<当前Git短SHA>
2. 备份原生 DobotAI 二进制和 systemd Unit,并写入 SHA-256;
3. 安装 Compose、配置器、启动器和绑定脚本;
4. 生成权限 0600 的 /opt/dobotai-test/container/runtime.env;
5. 保持原生 dobotai-test.service 继续运行。
``` ```
它不会读取或打印环境变量值、不会修改数据库、不会启动容器。 镜像名、PostgreSQL镜像和构建元数据只由根目录`install-docker.sh`维护,用户不传`DOBOTAI_IMAGE`。脚本完成后数据库和迁移已就绪,应用尚未启动。然后执行:
## 6. 配置并启动 构建期Go依赖使用脚本内置的公开模块代理`https://goproxy.cn,direct`;它只存在于镜像构建命令,不写入DobotAI运行配置。
日常只需要执行:
```bash ```bash
sudo dobotai-dingtalk-configure sudo dobotai-dingtalk-configure
``` ```
该命令交互更新 `/etc/dobotai-test/dobotai.env`。每项都会显示用途和脱敏格式示例;已配置的值永不回显,直接回车表示保留。 配置器逐项显示用途和脱敏格式示例;已有值不会回显,直接回车表示保留。数据库连接串、数据库密码、Ingress Token和Admin Token由安装脚本生成,交互配置器不要求输入。
| 环境变量 | 用途 | 格式示例 | 主要业务连接配置:
| 变量 | 用途 | 示例 |
| --- | --- | --- | | --- | --- | --- |
| `DATABASE_URL` | DobotAI独立PostgreSQL连接串,不得填写WeKnora数据库 | `postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable` | | `WEKNORA_BASE_URL` | Docker内访问宿主机WeKnora | `http://host.docker.internal:8080` |
| `WEKNORA_BASE_URL` | 测试WeKnora地址 | `http://127.0.0.1:8080` | | `WEKNORA_API_KEY` | DobotAI专用WeKnora API Key | `<你的WeKnora API Key>` |
| `WEKNORA_API_KEY` | DobotAI使用的WeKnora API密钥 | `<你的WeKnora API Key>` | | `DOBOTAI_TENANT_KEY` | DobotAI租户隔离键 | `dobotai` |
| `DOBOTAI_INGRESS_TOKEN` | `/v1/qa`调用令牌 | `<随机长字符串>` | | `DOBOTAI_AGENT_BINDINGS` | 渠道到WeKnora Agent映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DOBOTAI_TENANT_KEY` | 与既有DobotAI数据一致的租户键 | `dobotai-test` |
| `DOBOTAI_AGENT_BINDINGS` | 钉钉会话到WeKnora Agent的映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` | | `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` |
| `DINGTALK_CLIENT_SECRET` | 钉钉应用Client Secret/AppSecret | `<你的钉钉 AppSecret>` | | `DINGTALK_CLIENT_SECRET` | 钉钉应用Secret/AppSecret | `<你的钉钉 AppSecret>` |
保存成功后,配置器自动调用启动器: 配置器成功后自动启动`dobotai-dingtalk`,并检查PostgreSQL健康、`http://127.0.0.1:8090/healthz`和容器运行状态。
```text
停止原生 dobotai-test.service
→ 启动 dobotai-dingtalk
→ 检查 http://127.0.0.1:8090/healthz
→ 健康成功后禁用原生服务
```
首次容器启动失败会恢复原生服务;容器版本升级失败会恢复上一容器镜像。
## 7. 首位管理员绑定 ## 7. 首位管理员绑定
仅当该租户不存在有效管理员时执行: 仅在租户没有有效管理员时执行:
```bash ```bash
sudo dobotai-dingtalk-bind-admin sudo dobotai-dingtalk-bind-admin
``` ```
终端会显示一个10分钟有效的随机六码。候选管理员必须私聊钉钉机器人发送: 终端显示10分钟有效的一次性六码。候选管理员私聊钉钉机器人:
```text ```text
/setting <六码绑定码> /setting <六码绑定码>
``` ```
验证成功后,终端自动写入 `super_admin/active` 并退出。已有有效管理员时命令拒绝执行,防止借由该入口追加管理员或意外提权。 终端自动确认后写入`super_admin/active`。已有管理员时命令拒绝执行,防止意外提权。
## 8. 覆盖判定与未覆盖提醒 ## 8. 迁移现有DobotAI实例
覆盖判定基于真实的 `knowledge_searched` 工具事件和结构化引用,不按关键词猜测。 旧实例已有Turn、会话、Support Case、管理员或反馈时,不能直接新建空库后切换。先安装目标Compose基础设施,再运行:
| 场景 | 覆盖结果 | 是否创建 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. 再替换容器镜像并进行健康与业务验收。
```
`000021_coverage_non_knowledge_response_reason` 是一个示例:它只扩展 `coverage_decisions.reason_code` 的 CHECK 约束,不回写历史Turn、覆盖判定或Support Case。
## 10. 日常检查
```bash ```bash
# 健康检查 sudo DOBOTAI_HOME=/opt/dobotai-compose \
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz SOURCE_POSTGRES_CONTAINER=dobotai-test-postgres \
SOURCE_APP_CONTAINER=dobotai-dingtalk \
SOURCE_DOBOTAI_ENV=/etc/dobotai-test/dobotai.env \
/opt/dobotai-compose/container/migrate-existing-database.sh
```
# 运行版本 迁移脚本固定顺序:
```text
启动目标 dobotai-postgres 并等待健康
→ 停止并改名旧应用容器(不删除)
→ pg_dump -Fc 导出旧数据库
→ pg_restore --list 验证备份可读
→ 写入备份SHA-256
→ 复制旧应用配置,只替换数据库、监听和WeKnora地址
→ 恢复到 dobotai-postgres-data
→ 应用缺失迁移
→ 启动新 dobotai-dingtalk
→ 健康失败则恢复旧容器名称和旧容器
```
源数据库不会删除。成功后旧应用容器会以`*-legacy-*`保留,确认稳定前不得删除旧容器或旧数据库。
## 9. 数据库迁移
DobotAI应用本身不扫描SQL。安装/迁移流程由:
```bash
sudo /opt/dobotai/container/apply-migrations.sh
```
执行`migrations/*.up.sql`,每个成功迁移在同一事务写入:
```text
dobotai_schema_migrations(version, name, applied_at)
```
任何数据库升级前都必须有经`pg_restore --list`验证的逻辑备份。`.down.sql`只能在审查通过后用于数据库回滚,不能用应用镜像回滚替代数据库回滚。
## 10. 日常运行与升级
```bash
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version curl --noproxy '*' -fsS http://127.0.0.1:8090/version
# 容器状态与重启次数 # Compose状态
sudo docker inspect -f '{{.Config.Image}}|{{.State.Running}}|{{.RestartCount}}' dobotai-dingtalk sudo docker compose --env-file /opt/dobotai/container/runtime.env \
-f /opt/dobotai/container/compose.yaml ps
# 最近日志(不要复制含敏感内容的原始上下文) # 日志:不要转发含用户内容或凭据的原始上下文
sudo docker logs --since 10m dobotai-dingtalk sudo docker logs --since 10m dobotai-dingtalk
# 数据库状态
sudo docker inspect -f '{{.State.Health.Status}}|{{.RestartCount}}' dobotai-postgres
``` ```
验收至少应同时满足: 升级顺序:导入候选镜像 → 备份并应用必要迁移 → 更新`runtime.env`中的`DOBOTAI_IMAGE` → 执行:
```bash
sudo dobotai-dingtalk-start
```
应用健康失败时启动器恢复上一应用镜像;数据库迁移仍必须按审查过的SQL回滚流程处理。
## 11. 覆盖判定边界
```text ```text
容器 running,重启次数为0; 有结构化引用 → covered_has_references
/healthz 返回 ok; 实际检索过但没有引用 → uncovered_no_references → Support Case
/version 的 commit_sha 与发布记录一致; 明确无权威知识/无答案 → uncovered_explicit_no_answer → Support Case
钉钉 Stream 仅启动一个消费者; 图片未检索、纯视觉识别 → covered_visual_recognition
旧 dobotai-test.service 为 inactive + disabled; 文本未检索、闲聊/普通非知识响应 → covered_non_knowledge_response
目标业务路径已由真实钉钉消息验证。
``` ```
## 11. 升级与回滚 因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会创建Support Case或发送未覆盖提醒。
### 升级 ## 12. 禁止操作
```text ```text
构建/导入经审查的新镜像 docker compose down -v
→ 备份DobotAI PostgreSQL docker volume rm dobotai-postgres-data
→ 应用并记录必要迁移 docker system prune --volumes
→ 将 runtime.env 中的 DOBOTAI_IMAGE 更新为新标签 把 config/*.env、secrets/、备份Dump、真实Token或凭据提交Git
→ 执行 sudo dobotai-dingtalk-start 在生产重新编译或使用未经测试的镜像
→ 健康、版本、日志、钉钉真实消息验收
```
注意拼写:数据库和应用名称是 `DobotAI`;上述流程中的镜像名为 `dobotai-dingtalk`。
### 自动回滚
启动器会保存替换前的容器镜像:
```text
首次容器化失败:停止候选容器,恢复原生服务;
后续容器升级失败:停止候选容器,恢复上一容器镜像。
```
数据库迁移不能仅靠容器回滚撤销。只有经过审查且已验证的 `.down.sql` 才可用于数据库回滚;任何数据恢复都必须先确认备份可用。
## 12. 明确非目标
```text
不容器化或重建 WeKnora;
不容器化或删除 DobotAI PostgreSQL;
不重新启用已删除的网页管理面;
不在生产环境复用测试凭据、测试镜像或未验收的迁移;
不把密钥、Token、连接串、钉钉凭据写入 Git、命令行或日志。
``` ```
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
migrations_dir="$container_dir/migrations"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" || ! -d "$migrations_dir" ]]; then
echo "迁移运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d --no-build dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 未健康,拒绝迁移。" >&2; exit 1; }
psql_db() {
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai "$@"
}
psql_db -c 'CREATE TABLE IF NOT EXISTS dobotai_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())'
for file in "$migrations_dir"/*.up.sql; do
[[ -f "$file" ]] || continue
base="$(basename "$file")"
version_text="${base%%_*}"
version=$((10#$version_text))
name="${base#*_}"
name="${name%.up.sql}"
case "$name" in *[!a-z0-9_]* ) echo "非法迁移文件名:$base" >&2; exit 1;; esac
applied=$(psql_db -At -c "SELECT EXISTS(SELECT 1 FROM dobotai_schema_migrations WHERE version=$version)")
[[ "$applied" == "t" ]] && continue
{
printf 'BEGIN;\n'
cat "$file"
printf "\nINSERT INTO dobotai_schema_migrations(version,name) VALUES (%s,'%s');\nCOMMIT;\n" "$version" "$name"
} | "${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai
echo "已应用迁移:$base"
done
+36 -2
View File
@@ -1,9 +1,28 @@
services: services:
dobotai-postgres:
image: ${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}
container_name: dobotai-postgres
env_file:
- ${DOBOTAI_POSTGRES_ENV_FILE:?set DOBOTAI_POSTGRES_ENV_FILE in runtime.env}
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 30
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
dobotai-dingtalk: dobotai-dingtalk:
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env} image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
container_name: dobotai-dingtalk container_name: dobotai-dingtalk
network_mode: host depends_on:
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}" dobotai-postgres:
condition: service_healthy
env_file: env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env} - ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment: environment:
@@ -13,6 +32,10 @@ services:
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env} source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets target: /var/lib/dobotai/secrets
read_only: false read_only: false
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
init: true init: true
read_only: true read_only: true
@@ -20,3 +43,14 @@ services:
- /tmp:rw,noexec,nosuid,size=32m - /tmp:rw,noexec,nosuid,size=32m
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
networks:
- dobotai-network
volumes:
dobotai-postgres-data:
name: dobotai-postgres-data
networks:
dobotai-network:
name: dobotai-network
driver: bridge
+5 -7
View File
@@ -6,11 +6,10 @@ from pathlib import Path
import sys import sys
FIELDS = ( FIELDS = (
("DATABASE_URL", "DobotAI PostgreSQL 连接串", "DobotAI独立数据库,不得填写WeKnora数据库。", "postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable", True), ("WEKNORA_BASE_URL", "WeKnora 地址", "Docker 容器访问宿主机 WeKnora 时使用 host.docker.internal。", "http://host.docker.internal:8080", False),
("WEKNORA_BASE_URL", "WeKnora 地址", "容器通过宿主网络访问测试WeKnora。", "http://127.0.0.1:8080", False), ("WEKNORA_API_KEY", "WeKnora API Key", "在 WeKnora 中为 DobotAI 创建的独立 API Key。", "<你的WeKnora API Key>", True),
("WEKNORA_API_KEY", "WeKnora API Key", "在WeKnora中为DobotAI创建的API密钥。", "<你的WeKnora API Key>", True),
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True), ("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True),
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "必须与现有DobotAI数据和Agent绑定使用同一个租户键。", "dobotai-test", False), ("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "同一 DobotAI 数据库内的渠道隔离键。", "dobotai", False),
("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话匹配到 WeKnora Agent 的映射。", '{"dingtalk:*":"你的Agent ID"}', 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_ID", "钉钉 Client ID", "钉钉应用的 Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的 Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True), ("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的 Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
@@ -56,14 +55,13 @@ def write_env(path: Path, lines, values):
def main(): def main():
parser = argparse.ArgumentParser(description="配置 DobotAI 容器运行环境") parser = argparse.ArgumentParser(description="配置 DobotAI Docker 应用连接")
parser.add_argument("--env-file", required=True) parser.add_argument("--env-file", required=True)
parser.add_argument("--start-script", required=True) parser.add_argument("--start-script", required=True)
args = parser.parse_args() args = parser.parse_args()
env_file = Path(args.env_file) env_file = Path(args.env_file)
if not env_file.is_file(): if not env_file.is_file():
raise SystemExit("DobotAI 环境文件不存在,请先完成部署。") raise SystemExit("DobotAI 环境文件不存在,请先完成 Docker 安装。")
lines, values = parse_env(env_file) lines, values = parse_env(env_file)
for key, label, description, example, secret in FIELDS: for key, label, description, example, secret in FIELDS:
entered = prompt(label, description, example, secret, bool(values.get(key, "").strip())) entered = prompt(label, description, example, secret, bool(values.get(key, "").strip()))
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
container="dobotai-dingtalk" home="${DOBOTAI_HOME:-/opt/dobotai}"
if [[ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null || true)" != "true" ]]; then 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 echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2
exit 1 exit 1
fi fi
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
+11 -30
View File
@@ -1,23 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
container_dir="/opt/dobotai-test/container" home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml" compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env" runtime_env="$container_dir/runtime.env"
legacy_service="dobotai-test.service"
health_url="http://127.0.0.1:8090/healthz" health_url="http://127.0.0.1:8090/healthz"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
echo "容器运行文件未安装。请先部署 dobotai-dingtalk。" >&2 echo "Docker 运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1 exit 1
fi fi
legacy_was_active=false compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
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)" previous_image="$(docker inspect --format '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
set_runtime_image() { set_runtime_image() {
@@ -26,19 +21,11 @@ set_runtime_image() {
from pathlib import Path from pathlib import Path
import os import os
import sys import sys
path = Path(sys.argv[1]) path = Path(sys.argv[1])
image = sys.argv[2] image = sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines() lines = path.read_text(encoding="utf-8").splitlines()
updated = False rendered = ["DOBOTAI_IMAGE=" + image if line.startswith("DOBOTAI_IMAGE=") else line for line in lines]
rendered = [] if not any(line.startswith("DOBOTAI_IMAGE=") for line in lines):
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) rendered.append("DOBOTAI_IMAGE=" + image)
temporary = path.with_name(path.name + ".new") temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8") temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
@@ -48,20 +35,18 @@ PY
} }
rollback() { rollback() {
docker compose --env-file "$runtime_env" -f "$compose_file" down --remove-orphans >/dev/null 2>&1 || true "${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then if [[ -n "$previous_image" ]]; then
set_runtime_image "$previous_image" 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 "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true
elif [[ "$legacy_was_active" == "true" ]]; then
systemctl start "$legacy_service" || true
fi fi
} }
trap rollback ERR trap rollback ERR
docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate "${compose[@]}" up -d --no-build dobotai-postgres
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
systemctl disable "$legacy_service" >/dev/null
trap - ERR trap - ERR
echo "DobotAI 容器已启动:dobotai-dingtalk" echo "DobotAI 容器已启动:dobotai-dingtalk"
exit 0 exit 0
@@ -69,9 +54,5 @@ for _ in $(seq 1 30); do
sleep 1 sleep 1
done done
if [[ -n "$previous_image" ]]; then echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像(如存在)。" >&2
echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像。" >&2
else
echo "DobotAI 容器健康检查失败,已回滚到原生服务。" >&2
fi
exit 1 exit 1
+121
View File
@@ -0,0 +1,121 @@
#!/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"
postgres_env="$config_dir/postgres.env"
runtime_env="$container_dir/runtime.env"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to a reviewed DobotAI image tag}"
postgres_image="${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}"
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行 Docker 安装脚本。" >&2
exit 1
fi
command -v docker >/dev/null || { echo "Docker 未安装。" >&2; exit 1; }
docker compose version >/dev/null || { echo "Docker Compose 未安装。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "候选应用镜像不存在:$image" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir"
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
install -d -m 0750 "$container_dir/migrations"
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/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 0750 "$script_dir/apply-migrations.sh" "$container_dir/apply-migrations.sh"
install -m 0750 "$script_dir/migrate-existing-database.sh" "$container_dir/migrate-existing-database.sh"
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
install -m 0644 "$source_root"/db/migrations/*.down.sql "$container_dir/migrations/"
if [[ ! -f "$postgres_env" || ! -f "$app_env" ]]; then
umask 077
python3 - "$postgres_env" "$app_env" <<'PY'
from pathlib import Path
import os
import secrets
import sys
postgres_env = Path(sys.argv[1])
app_env = Path(sys.argv[2])
password = secrets.token_urlsafe(32)
ingress = secrets.token_urlsafe(32)
admin = secrets.token_urlsafe(32)
postgres_env.write_text(
"POSTGRES_DB=dobotai\n"
"POSTGRES_USER=dobotai\n"
f"POSTGRES_PASSWORD={password}\n",
encoding="utf-8",
)
os.chmod(postgres_env, 0o600)
app_env.write_text(
"DOBOTAI_LISTEN_ADDR=0.0.0.0:8090\n"
f"DATABASE_URL=postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable\n"
f"DOBOTAI_INGRESS_TOKEN={ingress}\n"
f"DOBOTAI_ADMIN_TOKEN={admin}\n"
"DOBOTAI_GROUP_SESSION_POLICY=shared\n"
"DOBOTAI_TENANT_KEY=dobotai\n"
"DOBOTAI_AGENT_BINDINGS=\n"
"DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer\n"
"DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning\n"
"WEKNORA_BASE_URL=http://host.docker.internal:8080\n"
"WEKNORA_API_KEY=\n"
"DINGTALK_CLIENT_ID=\n"
"DINGTALK_CLIENT_SECRET=\n",
encoding="utf-8",
)
os.chmod(app_env, 0o600)
PY
fi
umask 077
cat > "$runtime_env" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_POSTGRES_IMAGE=$postgres_image
DOBOTAI_ENV_FILE=$app_env
DOBOTAI_POSTGRES_ENV_FILE=$postgres_env
DOBOTAI_SECRET_DIR_HOST=$secret_dir
DOBOTAI_MIGRATIONS_DIR=$container_dir/migrations
EOF
chmod 0600 "$runtime_env"
install -d -m 0755 "$bin_dir"
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --start-script '$bin_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-start" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-bind-admin" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-bind-admin'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure" "$bin_dir/dobotai-dingtalk-start" "$bin_dir/dobotai-dingtalk-bind-admin"
compose=(docker compose --env-file "$runtime_env" -f "$container_dir/compose.yaml")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 初始化失败。" >&2; exit 1; }
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
echo "Docker 基础设施已准备完成:数据库与迁移已就绪,应用尚未启动。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
+1 -51
View File
@@ -1,54 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}" exec "$script_dir/install-docker.sh" "$@"
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行安装脚本。" >&2
exit 1
fi
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "候选镜像不存在:$image" >&2
exit 1
fi
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
exit 1
fi
uid="$(id -u "$runtime_user")"
gid="$(id -g "$runtime_user")"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
backup_dir="$backup_root/pre-container-$stamp"
install -d -m 0700 "$backup_dir" "$container_dir"
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
runtime_tmp="$container_dir/runtime.env.new"
umask 077
cat > "$runtime_tmp" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_RUNTIME_UID=$uid
DOBOTAI_RUNTIME_GID=$gid
DOBOTAI_ENV_FILE=$env_file
DOBOTAI_SECRET_DIR_HOST=$secret_dir
EOF
chmod 0600 "$runtime_tmp"
mv -f "$runtime_tmp" "$container_dir/runtime.env"
echo "容器资产已安装,旧服务仍在运行。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
runtime_env="$container_dir/runtime.env"
compose_file="$container_dir/compose.yaml"
source_container="${SOURCE_POSTGRES_CONTAINER:-dobotai-test-postgres}"
source_app_container="${SOURCE_APP_CONTAINER:-dobotai-dingtalk}"
source_env="${SOURCE_DOBOTAI_ENV:-/etc/dobotai-test/dobotai.env}"
app_env="$config_dir/dobotai.env"
backup_dir="$home/backups"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
legacy_app="${source_app_container}-legacy-${stamp}"
[[ ${EUID} -eq 0 ]] || { echo "请使用 sudo 执行现有数据库迁移脚本。" >&2; exit 1; }
[[ -r "$runtime_env" && -r "$compose_file" && -r "$source_env" ]] || { echo "运行文件或旧环境文件不存在。请先执行 install-docker.sh。" >&2; exit 1; }
docker inspect "$source_container" >/dev/null 2>&1 || { echo "源 PostgreSQL 容器不存在:$source_container" >&2; exit 1; }
docker inspect "$source_app_container" >/dev/null 2>&1 || { echo "源 DobotAI 容器不存在:$source_app_container" >&2; exit 1; }
source_pg_user=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_USER=')), ''))")
source_pg_db=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_DB=')), ''))")
test -n "$source_pg_user" && test -n "$source_pg_db"
install -d -m 0700 "$backup_dir"
dump="$backup_dir/pre-compose-adoption-${stamp}.dump"
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "目标 PostgreSQL 未健康。" >&2; exit 1; }
old_running=$(docker inspect -f '{{.State.Running}}' "$source_app_container")
rollback() {
docker compose --env-file "$runtime_env" -f "$compose_file" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if docker inspect "$legacy_app" >/dev/null 2>&1; then
docker rename "$legacy_app" "$source_app_container" || true
[[ "$old_running" == "true" ]] && docker start "$source_app_container" >/dev/null 2>&1 || true
fi
}
trap rollback ERR
[[ "$old_running" == "true" ]] && docker stop "$source_app_container" >/dev/null
docker rename "$source_app_container" "$legacy_app"
docker exec -e PGUSER="$source_pg_user" "$source_container" pg_dump -Fc --no-owner -d "$source_pg_db" > "$dump"
test -s "$dump"
docker exec -i dobotai-postgres pg_restore --list < "$dump" >/dev/null
sha256sum "$dump" > "$dump.sha256"
# Replace only deployment-managed endpoints; preserve all existing application credentials.
python3 - "$source_env" "$app_env" "$config_dir/postgres.env" <<'PY'
from pathlib import Path
import os
import sys
source, target, postgres_env = map(Path, sys.argv[1:])
password = next(line.split('=', 1)[1].strip() for line in postgres_env.read_text().splitlines() if line.startswith('POSTGRES_PASSWORD='))
values = {}
for line in source.read_text().splitlines():
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
values[key] = value
values['DOBOTAI_LISTEN_ADDR'] = '0.0.0.0:8090'
values['DATABASE_URL'] = f'postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable'
values['WEKNORA_BASE_URL'] = 'http://host.docker.internal:8080'
temporary = target.with_name(target.name + '.new')
temporary.write_text('\n'.join(f'{k}={v}' for k, v in values.items()) + '\n', encoding='utf-8')
os.chmod(temporary, 0o600)
os.replace(temporary, target)
PY
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
docker exec -i dobotai-postgres pg_restore -U dobotai -d dobotai --no-owner < "$dump"
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
"${compose[@]}" up -d --no-build dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
trap - ERR
echo "现有DobotAI数据库已迁移到 dobotai-postgres-data;旧应用容器保留为 $legacy_app。"
exit 0
fi
sleep 1
done
echo "迁移后的应用健康检查失败,正在恢复旧容器。" >&2
exit 1
+6 -5
View File
@@ -1,6 +1,7 @@
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。 # 非敏感运行参数;由 install-docker.sh 生成实际文件。
DOBOTAI_IMAGE=dobotai-dingtalk:test DOBOTAI_IMAGE=dobotai-dingtalk:test
DOBOTAI_RUNTIME_UID=996 DOBOTAI_POSTGRES_IMAGE=postgres:16-alpine
DOBOTAI_RUNTIME_GID=988 DOBOTAI_ENV_FILE=/opt/dobotai/config/dobotai.env
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env DOBOTAI_POSTGRES_ENV_FILE=/opt/dobotai/config/postgres.env
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets DOBOTAI_SECRET_DIR_HOST=/opt/dobotai/secrets
DOBOTAI_MIGRATIONS_DIR=/opt/dobotai/container/migrations
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
# 唯一安装入口:镜像仓库名与标签策略只在本脚本维护。
postgres_image="postgres:16-alpine"
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 -C "$repo_root" 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 "本脚本仅自动安装基于 apt 的 Docker;请先安装 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
docker compose version >/dev/null
}
install_docker_if_missing
commit_sha="$(git -C "$repo_root" rev-parse HEAD)"
commit_short="$(git -C "$repo_root" rev-parse --short=12 HEAD)"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
image="dobotai-dingtalk:local-${commit_short}"
echo "[1/3] 构建 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=${build_time}" \
--label "org.opencontainers.image.revision=${commit_sha}" \
--tag "$image" \
"$repo_root"
echo "[2/3] 准备独立PostgreSQL、目录、配置文件和迁移"
export DOBOTAI_IMAGE="$image"
export DOBOTAI_POSTGRES_IMAGE="$postgres_image"
export DOBOTAI_SOURCE_ROOT="$repo_root"
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
+93 -14
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testing.T) { func TestDobotAIDingTalkComposeOwnsApplicationAndPostgres(t *testing.T) {
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml") compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -14,7 +14,13 @@ func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testi
for _, required := range []string{ for _, required := range []string{
"dobotai-dingtalk:", "dobotai-dingtalk:",
"container_name: dobotai-dingtalk", "container_name: dobotai-dingtalk",
"network_mode: host", "dobotai-postgres:",
"container_name: dobotai-postgres",
"dobotai-postgres-data:",
"depends_on:",
"condition: service_healthy",
"host.docker.internal:host-gateway",
"127.0.0.1:8090:8090",
"env_file:", "env_file:",
"read_only: true", "read_only: true",
"/var/lib/dobotai/secrets", "/var/lib/dobotai/secrets",
@@ -28,11 +34,7 @@ func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testi
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, required := range []string{ for _, required := range []string{"configure_env.py", "dobotai-dingtalk-start"} {
"configure_env.py",
"/etc/dobotai-test/dobotai.env",
"dobotai-dingtalk-start",
} {
if !strings.Contains(string(configure), required) { if !strings.Contains(string(configure), required) {
t.Errorf("configure command missing %q", required) t.Errorf("configure command missing %q", required)
} }
@@ -42,10 +44,7 @@ func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testi
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, required := range []string{ for _, required := range []string{"docker exec -it dobotai-dingtalk", "/usr/local/bin/dobotai bootstrap-admin"} {
"docker exec -it dobotai-dingtalk",
"/usr/local/bin/dobotai bootstrap-admin",
} {
if !strings.Contains(string(bind), required) { if !strings.Contains(string(bind), required) {
t.Errorf("bind command missing %q", required) t.Errorf("bind command missing %q", required)
} }
@@ -83,8 +82,7 @@ func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExa
t.Fatal("configuration guidance must label every example") t.Fatal("configuration guidance must label every example")
} }
for _, example := range []string{ for _, example := range []string{
"postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable", "http://host.docker.internal:8080",
"http://127.0.0.1:8080",
`{"dingtalk:*":"你的Agent ID"}`, `{"dingtalk:*":"你的Agent ID"}`,
"DINGTALK_CLIENT_SECRET", "DINGTALK_CLIENT_SECRET",
} { } {
@@ -95,6 +93,12 @@ func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExa
t.Errorf("documentation missing redacted example %q", example) t.Errorf("documentation missing redacted example %q", example)
} }
} }
if strings.Contains(string(configurator), "DATABASE_URL") {
t.Fatal("database URL must be deployment-managed, not requested interactively")
}
if !strings.Contains(string(documentation), "dobotai-postgres") {
t.Fatal("documentation must explain the Compose-managed database")
}
} }
func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) { func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) {
@@ -108,10 +112,85 @@ func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) {
"set_runtime_image", "set_runtime_image",
"docker inspect --format", "docker inspect --format",
"set_runtime_image \"$previous_image\"", "set_runtime_image \"$previous_image\"",
"docker compose --env-file \"$runtime_env\" -f \"$compose_file\" up -d --no-build --force-recreate", "\"${compose[@]}\" up -d --no-build --force-recreate dobotai-dingtalk",
} { } {
if !strings.Contains(source, required) { if !strings.Contains(source, required) {
t.Errorf("start script missing container upgrade rollback %q", required) t.Errorf("start script missing container upgrade rollback %q", required)
} }
} }
} }
func TestDobotAIDingTalkDockerInstallerPreparesDirectoriesDatabaseAndMigrations(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/install-docker.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"container", "secrets", "backups", "migrations", "postgres.env",
"dobotai-postgres", "apply-migrations.sh", "docker compose", "umask 077",
} {
if !strings.Contains(source, required) {
t.Errorf("docker installer missing %q", required)
}
}
if strings.Contains(source, "dobotai-dingtalk up -d") {
t.Fatal("installer must prepare database but must not start the unconfigured application")
}
}
func TestDobotAIDingTalkExistingDatabaseMigrationHasBackupAndRollbackBoundary(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/migrate-existing-database.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"pg_dump -Fc", "pg_restore --list", "dobotai-postgres-data",
"dobotai-dingtalk", "rollback", "apply-migrations.sh",
} {
if !strings.Contains(source, required) {
t.Errorf("database adoption script missing %q", required)
}
}
}
func TestRootDockerInstallerIsCloneAndRunEntryPoint(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",
"docker compose version",
"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") || strings.Contains(source, "DOBOTAI_IMAGE=?") {
t.Fatal("root installer must not require an operator supplied DOBOTAI_IMAGE")
}
}
func TestRootDockerInstallerBuildsWithInternalGoModuleProxy(t *testing.T) {
installer, err := os.ReadFile("../install-docker.sh")
if err != nil {
t.Fatal(err)
}
dockerfile, err := os.ReadFile("../Dockerfile")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(installer), "GOPROXY=https://goproxy.cn,direct") {
t.Fatal("root installer must pass an internal default GOPROXY to docker build")
}
if !strings.Contains(string(dockerfile), "ARG GOPROXY") || !strings.Contains(string(dockerfile), "GOPROXY=$GOPROXY go mod download") {
t.Fatal("Dockerfile must accept and use the root installer GOPROXY")
}
}