From c46ec1863200a3714419a13654bcf34e5e71a7d8 Mon Sep 17 00:00:00 2001 From: zonglin zhang <953439728@qq.com> Date: Fri, 3 Apr 2026 15:08:35 +0800 Subject: [PATCH 01/10] add helm chart to support K8S deployment --- helm/QUICKSTART.md | 705 +++++++++++++++++++++++ helm/QUICKSTART_EN.md | 705 +++++++++++++++++++++++ helm/clawith/Chart.yaml | 13 + helm/clawith/README.md | 0 helm/clawith/templates/_helpers.tpl | 138 +++++ helm/clawith/templates/backend.yaml | 141 +++++ helm/clawith/templates/frontend.yaml | 56 ++ helm/clawith/templates/ingress.yaml | 36 ++ helm/clawith/templates/namespace.yaml | 20 + helm/clawith/templates/postgresql.yaml | 183 ++++++ helm/clawith/templates/redis.yaml | 93 +++ helm/clawith/templates/secrets.yaml | 0 helm/clawith/templates/storageclass.yaml | 17 + helm/clawith/values.yaml | 228 ++++++++ 14 files changed, 2335 insertions(+) create mode 100644 helm/QUICKSTART.md create mode 100644 helm/QUICKSTART_EN.md create mode 100644 helm/clawith/Chart.yaml create mode 100644 helm/clawith/README.md create mode 100644 helm/clawith/templates/_helpers.tpl create mode 100644 helm/clawith/templates/backend.yaml create mode 100644 helm/clawith/templates/frontend.yaml create mode 100644 helm/clawith/templates/ingress.yaml create mode 100644 helm/clawith/templates/namespace.yaml create mode 100644 helm/clawith/templates/postgresql.yaml create mode 100644 helm/clawith/templates/redis.yaml create mode 100644 helm/clawith/templates/secrets.yaml create mode 100644 helm/clawith/templates/storageclass.yaml create mode 100644 helm/clawith/values.yaml diff --git a/helm/QUICKSTART.md b/helm/QUICKSTART.md new file mode 100644 index 000000000..279dae83d --- /dev/null +++ b/helm/QUICKSTART.md @@ -0,0 +1,705 @@ +# Clawith Helm 部署快速开始指南 + +## 📋 目录结构 + +``` +helm/ +├── clawith/ # Helm Chart 主目录 +│ ├── Chart.yaml # Chart 元数据 +│ ├── values.yaml # 配置文件 +│ ├── README.md # 详细文档 +│ └── templates/ # Kubernetes 资源模板 +│ ├── _helpers.tpl # 模板辅助函数 +│ ├── namespace.yaml # Namespace +│ ├── secrets.yaml # 密钥 +│ ├── backend.yaml # 后端服务 +│ ├── frontend.yaml # 前端服务 +│ ├── ingress.yaml # Ingress 配置 +│ ├── postgresql.yaml # PostgreSQL 数据库 +│ ├── redis.yaml # Redis 缓存 +│ └── storageclass.yaml # 存储类(可选) +└── QUICKSTART.md # 本文档 +``` + +## 🚀 快速开始 + +### 1. 编辑配置文件 + +编辑 `helm/clawith/values.yaml`,根据你的环境修改以下配置: + +```bash +vi helm/clawith/values.yaml +``` + +**必须修改的配置项:** + +```yaml +# 1. 配置镜像仓库地址 +global: + imageRegistry: docker.io/yourusername # 修改为你的镜像仓库地址 + +# 2. 配置镜像标签 +backend: + image: + tag: latest # 建议使用具体版本号,如 v1.0.0 + +frontend: + image: + tag: latest # 建议使用具体版本号,如 v1.0.0 + +# 3. 配置存储 +backend: + persistence: + existingClaim: "" # 如果使用现有 PVC,填入 PVC 名称 + storageClass: "" # 如果新建,则修改为你的 StorageClass 名称 + size: 10Gi + +postgresql: + image: + registry: docker.io/bitnami # 修改为你的镜像仓库 + auth: + password: "clawith123456" # 强烈建议修改为强密码! + primary: + persistence: + existingClaim: "" # 如果使用现有 PVC,填入 PVC 名称 + storageClass: "" # 如果新建,则修改为你的 StorageClass 名称 + size: 8Gi + +redis: + image: + registry: docker.io # 修改为你的镜像仓库 + persistence: + existingClaim: "" # 如果使用现有 PVC,填入 PVC 名称 + storageClass: "" # 如果新建,则修改为你的 StorageClass 名称 + size: 2Gi + +# 4. 配置域名 +frontend: + ingress: + host: "clawith.example.com" # 修改为你的域名 + +# 5. 修改应用密钥(重要!) +backend: + secrets: + secretKey: "your-secret-key-at-least-50-characters-long" + jwtSecretKey: "your-jwt-secret-key-at-least-32-characters" + +# 6. 如果需要私签证书支持,启用 hostCerts +backend: + hostCerts: + enabled: false # 如果需要则设置为 true +``` + +### 2. 安装 + +```bash +helm install clawith ./helm/clawith -n clawith --create-namespace +``` + +### 3. 验证部署 + +```bash +# 查看 Pod 状态 +kubectl get pods -n clawith + +# 应该看到类似输出: +# NAME READY STATUS RESTARTS AGE +# clawith-backend-xxx 1/1 Running 0 2m +# clawith-frontend-xxx 1/1 Running 0 2m +# clawith-postgresql-0 1/1 Running 0 2m +# clawith-redis-xxx 1/1 Running 0 2m + +# 查看服务和 Ingress +kubectl get svc,ingress -n clawith +``` + +## 🔧 常见配置场景 + +### 场景 1:使用现有 PVC(已有存储) + +```yaml +backend: + persistence: + enabled: true + existingClaim: "clawith-agent-data-pvc" # 你的 PVC 名称 + # 不需要指定 storageClass 和 size + +postgresql: + primary: + persistence: + enabled: true + existingClaim: "clawith-postgresql-data" # 你的 PVC 名称 + +redis: + persistence: + enabled: true + existingClaim: "redisdata" # 你的 PVC 名称 +``` + +### 场景 2:创建新的 PVC(动态存储) + +```yaml +backend: + persistence: + enabled: true + existingClaim: "" # 留空 + storageClass: "nfs-client" # 你的 StorageClass 名称 + size: 10Gi + +postgresql: + primary: + persistence: + enabled: true + existingClaim: "" + storageClass: "nfs-client" + size: 8Gi + +redis: + persistence: + enabled: true + existingClaim: "" + storageClass: "nfs-client" + size: 2Gi +``` + +### 场景 3:配置镜像仓库 + +如果使用私有镜像仓库或不同的镜像源: + +```yaml +global: + imageRegistry: registry.example.com/myproject # 私有仓库 + +backend: + image: + repository: clawith-backend + tag: v1.0.0 # 使用具体版本号 + +frontend: + image: + repository: clawith-frontend + tag: v1.0.0 + +postgresql: + image: + registry: registry.example.com/bitnami + repository: bitnami/postgresql + tag: 15.3.0-debian-11-r7 + +redis: + image: + registry: registry.example.com + repository: redis + tag: 7-alpine +``` + +### 场景 4:启用私签证书支持 + +如果你的环境需要自定义 CA 证书(如企业内网环境): + +```yaml +backend: + hostCerts: + enabled: true + paths: + certs: /etc/ssl/certs + shareCA: /usr/local/share/ca-certificates + containerPaths: + sslCertFile: /app/cacert.pem + requestsCaBundle: /app/cacert.pem + curlCaBundle: /app/cacert.pem +``` + +### 场景 5:使用外部数据库 + +如果你有独立的 PostgreSQL 和 Redis 服务: + +```yaml +postgresql: + enabled: false + external: + host: "postgresql.example.com" + port: 5432 + database: clawith + username: postgres + password: "your-password" + +redis: + enabled: false + external: + host: "redis.example.com" + port: 6379 + database: 0 + password: "" # 如果有密码 +``` + +### 场景 6:生产环境配置 + +```yaml +global: + imageRegistry: registry.yourcompany.com/clawith + +backend: + replicaCount: 2 # 多副本 + image: + tag: v1.0.0 # 使用固定版本 + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi + persistence: + storageClass: "ssd-storage" # 高性能存储 + size: 50Gi + +frontend: + replicaCount: 2 + image: + tag: v1.0.0 + ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: "clawith.yourcompany.com" + tls: + enabled: true + secretName: clawith-tls-secret + +postgresql: + auth: + password: "STRONG_PASSWORD_HERE" # 必须使用强密码 + primary: + persistence: + storageClass: "ssd-storage" + size: 20Gi + resources: + limits: + cpu: 2000m + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + +redis: + persistence: + storageClass: "ssd-storage" + size: 5Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi +``` + +## 📝 常用命令 + +### 查看状态 + +```bash +# 查看所有资源 +kubectl get all -n clawith + +# 查看 Pod 状态 +kubectl get pods -n clawith + +# 查看 PVC +kubectl get pvc -n clawith + +# 查看 Helm 发布状态 +helm status clawith -n clawith + +# 查看 Helm 部署的值 +helm get values clawith -n clawith +``` + +### 查看日志 + +```bash +# 后端日志 +kubectl logs -n clawith -l app.kubernetes.io/component=backend -f + +# 前端日志 +kubectl logs -n clawith -l app.kubernetes.io/component=frontend -f + +# PostgreSQL 日志 +kubectl logs -n clawith -l app.kubernetes.io/component=postgresql -f + +# Redis 日志 +kubectl logs -n clawith -l app.kubernetes.io/component=redis -f +``` + +### 升级 + +```bash +# 修改 values.yaml 后升级 +helm upgrade clawith ./helm/clawith -n clawith + +# 或者使用 --set 覆盖特定值 +helm upgrade clawith ./helm/clawith -n clawith \ + --set backend.image.tag=v1.0.1 \ + --set frontend.image.tag=v1.0.1 + +# 升级镜像版本 +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=registry.example.com/newproject +``` + +### 回滚 + +```bash +# 查看历史版本 +helm history clawith -n clawith + +# 回滚到上一版本 +helm rollback clawith -n clawith + +# 回滚到指定版本 +helm rollback clawith 1 -n clawith +``` + +### 卸载 + +```bash +# 卸载应用(保留 PVC) +helm uninstall clawith -n clawith + +# 如需删除 PVC +kubectl delete pvc -n clawith --all + +# 删除 namespace +kubectl delete namespace clawith +``` + +## 🔍 访问应用 + +### 通过 Ingress 访问(推荐) + +如果配置了 Ingress,直接通过域名访问: +``` +http://clawith.example.com # 或你配置的域名 +``` + +### 通过 Port Forward 访问 + +如果没有配置 Ingress,可以使用端口转发: + +```bash +# 转发前端服务 +kubectl port-forward -n clawith svc/clawith-frontend 8080:80 + +# 然后访问 http://localhost:8080 +``` + +```bash +# 转发后端服务(用于 API 调试) +kubectl port-forward -n clawith svc/clawith-backend 8000:8000 + +# 然后访问 http://localhost:8000 +``` + +## 🛠️ 故障排查 + +### Pod 无法启动 + +```bash +# 查看 Pod 详情 +kubectl describe pod -n clawith + +# 查看日志 +kubectl logs -n clawith + +# 查看事件 +kubectl get events -n clawith --sort-by='.lastTimestamp' +``` + +### PVC 绑定失败 + +```bash +# 检查 PVC 状态 +kubectl get pvc -n clawith +kubectl describe pvc -n clawith + +# 检查 StorageClass +kubectl get storageclass + +# 检查 PV +kubectl get pv +``` + +### 镜像拉取失败 + +```bash +# 检查镜像配置 +helm get values clawith -n clawith | grep -A 3 image + +# 查看 Pod 事件 +kubectl describe pod -n clawith | grep -A 10 Events + +# 手动拉取镜像测试 +docker pull your-registry/clawith-backend:latest +``` + +### 数据库连接问题 + +```bash +# 检查 PostgreSQL 服务 +kubectl get svc -n clawith | grep postgresql + +# 检查数据库密码 +kubectl get secret -n clawith -o yaml | grep postgres-password + +# 进入后端 Pod 测试连接 +kubectl exec -it -n clawith deployment/clawith-backend -- /bin/bash +# 在 Pod 内测试 +nc -zv clawith-postgresql 5432 +``` + +## 🔐 安全建议 + +### 1. 修改默认密码 + +⚠️ **重要**:在部署前必须修改所有默认密码! + +```yaml +backend: + secrets: + secretKey: "生成一个至少 50 字符的随机字符串" + jwtSecretKey: "生成一个至少 32 字符的随机字符串" + +postgresql: + auth: + password: "生成一个强密码" # 不要使用默认的 clawith123456 +``` + +生成随机密码的方法: +```bash +# 生成 50 字符的随机字符串 +openssl rand -base64 36 + +# 或使用 Python +python3 -c "import secrets; print(secrets.token_urlsafe(50))" + +# 生成 32 字符的随机字符串 +openssl rand -base64 24 +``` + +### 2. 使用外部 Secrets + +在生产环境中,建议使用外部 Secret 管理: + +```bash +# 创建 Secret +kubectl create secret generic clawith-secrets \ + --from-literal=secret-key='your-secret-key' \ + --from-literal=jwt-secret-key='your-jwt-secret' \ + -n clawith + +# 在 values.yaml 中配置 +secrets: + create: false + existingSecret: "clawith-secrets" +``` + +### 3. 启用 HTTPS + +```yaml +frontend: + ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: "clawith.yourcompany.com" + tls: + enabled: true + secretName: clawith-tls-secret +``` + +### 4. 配置资源限制 + +```yaml +backend: + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi +``` + +### 5. 使用私有镜像仓库 + +```yaml +global: + imageRegistry: registry.yourcompany.com/clawith + +# 如果需要认证,创建 imagePullSecret +# kubectl create secret docker-registry regcred \ +# --docker-server=registry.yourcompany.com \ +# --docker-username=user \ +# --docker-password=password \ +# -n clawith +``` + +## 💡 实用技巧 + +### 预览部署内容 + +在实际部署前,先预览生成的 YAML: + +```bash +# 渲染模板但不安装 +helm template clawith ./helm/clawith -n clawith > preview.yaml + +# 或使用 --dry-run +helm install clawith ./helm/clawith -n clawith --dry-run --debug +``` + +### 比较配置差异 + +安装 helm-diff 插件来比较配置变更: + +```bash +# 安装插件 +helm plugin install https://github.com/databus23/helm-diff + +# 查看升级差异 +helm diff upgrade clawith ./helm/clawith -n clawith +``` + +### 导出当前配置 + +```bash +# 导出当前使用的 values +helm get values clawith -n clawith > current-values.yaml + +# 导出完整的 manifest +helm get manifest clawith -n clawith > current-manifest.yaml +``` + +### 只更新特定组件 + +```bash +# 只更新后端镜像版本 +helm upgrade clawith ./helm/clawith -n clawith \ + --set backend.image.tag=v1.0.1 \ + --reuse-values + +# 只更新前端配置 +helm upgrade clawith ./helm/clawith -n clawith \ + --set frontend.ingress.host=new.example.com \ + --reuse-values + +# 更新镜像仓库地址 +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=new-registry.com/project \ + --reuse-values +``` + +## 📊 监控和维护 + +### 查看资源使用 + +```bash +# 查看 Pod 资源使用 +kubectl top pods -n clawith + +# 查看 Node 资源使用 +kubectl top nodes + +# 查看 PVC 使用情况 +kubectl get pvc -n clawith +``` + +### 定期备份 + +**备份 PostgreSQL 数据:** + +```bash +# 导出数据库 +kubectl exec -n clawith clawith-postgresql-0 -- \ + pg_dump -U postgres clawith > backup-$(date +%Y%m%d).sql + +# 恢复数据库 +kubectl exec -i -n clawith clawith-postgresql-0 -- \ + psql -U postgres clawith < backup-20260402.sql +``` + +**备份 Helm 配置:** + +```bash +# 备份当前配置 +helm get values clawith -n clawith > backup-values-$(date +%Y%m%d).yaml +``` + +## 🎯 与原有 K8s 部署对比 + +| 特性 | 原有 K8s YAML | Helm Chart | +|------|--------------|------------| +| 配置管理 | 分散在多个文件 | 集中在 values.yaml | +| 版本控制 | 手动管理 | Helm 自动追踪 | +| 升级 | 逐个 apply | `helm upgrade` 一条命令 | +| 回滚 | 困难 | `helm rollback` 一条命令 | +| 参数化 | 需要手动替换 | 模板自动渲染 | +| 环境管理 | 复制多份 YAML | 一套模板 + values.yaml | +| 依赖管理 | 手动管理顺序 | Helm 自动处理 | +| 可维护性 | 低 | 高 | + +## 📚 更多信息 + +- **详细配置文档**:`helm/clawith/README.md` +- **Helm 官方文档**:https://helm.sh/docs/ +- **Kubernetes 文档**:https://kubernetes.io/docs/ + +## ❓ 常见问题 + +**Q: 如何查看当前使用的配置?** +```bash +helm get values clawith -n clawith +``` + +**Q: 如何只更新某个配置项而不影响其他配置?** +```bash +helm upgrade clawith ./helm/clawith -n clawith --reuse-values --set backend.image.tag=v1.0.1 +``` + +**Q: 卸载后如何保留数据?** +```bash +# Helm 卸载默认不会删除 PVC,数据会保留 +helm uninstall clawith -n clawith +# PVC 仍然存在,下次安装时可以继续使用 +``` + +**Q: 如何查看部署失败的原因?** +```bash +kubectl get events -n clawith --sort-by='.lastTimestamp' +kubectl logs -n clawith +helm status clawith -n clawith +``` + +**Q: 如何更换镜像仓库?** +```bash +# 方式1:修改 values.yaml 中的 global.imageRegistry +# 方式2:使用 --set 参数 +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=new-registry.com/project +``` + +**Q: 如何验证 StorageClass 是否可用?** +```bash +# 查看可用的 StorageClass +kubectl get storageclass + +# 查看 StorageClass 详情 +kubectl describe storageclass nfs-client +``` + +--- + +**祝你部署顺利!** 🎉 diff --git a/helm/QUICKSTART_EN.md b/helm/QUICKSTART_EN.md new file mode 100644 index 000000000..62dba0840 --- /dev/null +++ b/helm/QUICKSTART_EN.md @@ -0,0 +1,705 @@ +# Clawith Helm Deployment Quick Start Guide + +## 📋 Directory Structure + +``` +helm/ +├── clawith/ # Helm Chart Main Directory +│ ├── Chart.yaml # Chart Metadata +│ ├── values.yaml # Configuration File +│ ├── README.md # Detailed Documentation +│ └── templates/ # Kubernetes Resource Templates +│ ├── _helpers.tpl # Template Helper Functions +│ ├── namespace.yaml # Namespace +│ ├── secrets.yaml # Secrets +│ ├── backend.yaml # Backend Service +│ ├── frontend.yaml # Frontend Service +│ ├── ingress.yaml # Ingress Configuration +│ ├── postgresql.yaml # PostgreSQL Database +│ ├── redis.yaml # Redis Cache +│ └── storageclass.yaml # Storage Class (Optional) +└── QUICKSTART.md # This Document +``` + +## 🚀 Quick Start + +### 1. Edit Configuration File + +Edit `helm/clawith/values.yaml` and modify it according to your environment: + +```bash +vi helm/clawith/values.yaml +``` + +**Required Configuration Items:** + +```yaml +# 1. Configure Image Registry +global: + imageRegistry: docker.io/yourusername # Change to your image registry + +# 2. Configure Image Tags +backend: + image: + tag: latest # Recommended to use specific version, e.g., v1.0.0 + +frontend: + image: + tag: latest # Recommended to use specific version, e.g., v1.0.0 + +# 3. Configure Storage +backend: + persistence: + existingClaim: "" # If using existing PVC, enter PVC name + storageClass: "" # If creating new, change to your StorageClass name + size: 10Gi + +postgresql: + image: + registry: docker.io/bitnami # Change to your image registry + auth: + password: "clawith123456" # Strongly recommended to change to a strong password! + primary: + persistence: + existingClaim: "" # If using existing PVC, enter PVC name + storageClass: "" # If creating new, change to your StorageClass name + size: 8Gi + +redis: + image: + registry: docker.io # Change to your image registry + persistence: + existingClaim: "" # If using existing PVC, enter PVC name + storageClass: "" # If creating new, change to your StorageClass name + size: 2Gi + +# 4. Configure Domain +frontend: + ingress: + host: "clawith.example.com" # Change to your domain + +# 5. Modify Application Secrets (Important!) +backend: + secrets: + secretKey: "your-secret-key-at-least-50-characters-long" + jwtSecretKey: "your-jwt-secret-key-at-least-32-characters" + +# 6. Enable hostCerts if private certificate signing support is needed +backend: + hostCerts: + enabled: false # Set to true if needed +``` + +### 2. Install + +```bash +helm install clawith ./helm/clawith -n clawith --create-namespace +``` + +### 3. Verify Deployment + +```bash +# Check Pod status +kubectl get pods -n clawith + +# Expected output: +# NAME READY STATUS RESTARTS AGE +# clawith-backend-xxx 1/1 Running 0 2m +# clawith-frontend-xxx 1/1 Running 0 2m +# clawith-postgresql-0 1/1 Running 0 2m +# clawith-redis-xxx 1/1 Running 0 2m + +# Check Services and Ingress +kubectl get svc,ingress -n clawith +``` + +## 🔧 Common Configuration Scenarios + +### Scenario 1: Using Existing PVC (Existing Storage) + +```yaml +backend: + persistence: + enabled: true + existingClaim: "clawith-agent-data-pvc" # Your PVC name + # No need to specify storageClass and size + +postgresql: + primary: + persistence: + enabled: true + existingClaim: "clawith-postgresql-data" # Your PVC name + +redis: + persistence: + enabled: true + existingClaim: "redisdata" # Your PVC name +``` + +### Scenario 2: Creating New PVC (Dynamic Storage) + +```yaml +backend: + persistence: + enabled: true + existingClaim: "" # Leave empty + storageClass: "nfs-client" # Your StorageClass name + size: 10Gi + +postgresql: + primary: + persistence: + enabled: true + existingClaim: "" + storageClass: "nfs-client" + size: 8Gi + +redis: + persistence: + enabled: true + existingClaim: "" + storageClass: "nfs-client" + size: 2Gi +``` + +### Scenario 3: Configure Image Registry + +If using private image registry or different image sources: + +```yaml +global: + imageRegistry: registry.example.com/myproject # Private registry + +backend: + image: + repository: clawith-backend + tag: v1.0.0 # Use specific version + +frontend: + image: + repository: clawith-frontend + tag: v1.0.0 + +postgresql: + image: + registry: registry.example.com/bitnami + repository: bitnami/postgresql + tag: 15.3.0-debian-11-r7 + +redis: + image: + registry: registry.example.com + repository: redis + tag: 7-alpine +``` + +### Scenario 4: Enable Private Certificate Support + +If your environment requires custom CA certificates (e.g., corporate intranet): + +```yaml +backend: + hostCerts: + enabled: true + paths: + certs: /etc/ssl/certs + shareCA: /usr/local/share/ca-certificates + containerPaths: + sslCertFile: /app/cacert.pem + requestsCaBundle: /app/cacert.pem + curlCaBundle: /app/cacert.pem +``` + +### Scenario 5: Using External Database + +If you have independent PostgreSQL and Redis services: + +```yaml +postgresql: + enabled: false + external: + host: "postgresql.example.com" + port: 5432 + database: clawith + username: postgres + password: "your-password" + +redis: + enabled: false + external: + host: "redis.example.com" + port: 6379 + database: 0 + password: "" # If password is required +``` + +### Scenario 6: Production Environment Configuration + +```yaml +global: + imageRegistry: registry.yourcompany.com/clawith + +backend: + replicaCount: 2 # Multiple replicas + image: + tag: v1.0.0 # Use fixed version + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi + persistence: + storageClass: "ssd-storage" # High-performance storage + size: 50Gi + +frontend: + replicaCount: 2 + image: + tag: v1.0.0 + ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: "clawith.yourcompany.com" + tls: + enabled: true + secretName: clawith-tls-secret + +postgresql: + auth: + password: "STRONG_PASSWORD_HERE" # Must use strong password + primary: + persistence: + storageClass: "ssd-storage" + size: 20Gi + resources: + limits: + cpu: 2000m + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + +redis: + persistence: + storageClass: "ssd-storage" + size: 5Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi +``` + +## 📝 Common Commands + +### Check Status + +```bash +# View all resources +kubectl get all -n clawith + +# Check Pod status +kubectl get pods -n clawith + +# Check PVCs +kubectl get pvc -n clawith + +# Check Helm release status +helm status clawith -n clawith + +# Check Helm deployment values +helm get values clawith -n clawith +``` + +### View Logs + +```bash +# Backend logs +kubectl logs -n clawith -l app.kubernetes.io/component=backend -f + +# Frontend logs +kubectl logs -n clawith -l app.kubernetes.io/component=frontend -f + +# PostgreSQL logs +kubectl logs -n clawith -l app.kubernetes.io/component=postgresql -f + +# Redis logs +kubectl logs -n clawith -l app.kubernetes.io/component=redis -f +``` + +### Upgrade + +```bash +# Upgrade after modifying values.yaml +helm upgrade clawith ./helm/clawith -n clawith + +# Or use --set to override specific values +helm upgrade clawith ./helm/clawith -n clawith \ + --set backend.image.tag=v1.0.1 \ + --set frontend.image.tag=v1.0.1 + +# Upgrade image registry +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=registry.example.com/newproject +``` + +### Rollback + +```bash +# View revision history +helm history clawith -n clawith + +# Rollback to previous revision +helm rollback clawith -n clawith + +# Rollback to specific revision +helm rollback clawith 1 -n clawith +``` + +### Uninstall + +```bash +# Uninstall application (keep PVCs) +helm uninstall clawith -n clawith + +# If you need to delete PVCs +kubectl delete pvc -n clawith --all + +# Delete namespace +kubectl delete namespace clawith +``` + +## 🔍 Access Application + +### Access via Ingress (Recommended) + +If Ingress is configured, access directly via domain: +``` +http://clawith.example.com # Or your configured domain +``` + +### Access via Port Forward + +If Ingress is not configured, use port forwarding: + +```bash +# Forward frontend service +kubectl port-forward -n clawith svc/clawith-frontend 8080:80 + +# Then access http://localhost:8080 +``` + +```bash +# Forward backend service (for API debugging) +kubectl port-forward -n clawith svc/clawith-backend 8000:8000 + +# Then access http://localhost:8000 +``` + +## 🛠️ Troubleshooting + +### Pod Cannot Start + +```bash +# Check Pod details +kubectl describe pod -n clawith + +# Check logs +kubectl logs -n clawith + +# Check events +kubectl get events -n clawith --sort-by='.lastTimestamp' +``` + +### PVC Binding Failure + +```bash +# Check PVC status +kubectl get pvc -n clawith +kubectl describe pvc -n clawith + +# Check StorageClass +kubectl get storageclass + +# Check PV +kubectl get pv +``` + +### Image Pull Failure + +```bash +# Check image configuration +helm get values clawith -n clawith | grep -A 3 image + +# Check Pod events +kubectl describe pod -n clawith | grep -A 10 Events + +# Manually test image pull +docker pull your-registry/clawith-backend:latest +``` + +### Database Connection Issues + +```bash +# Check PostgreSQL service +kubectl get svc -n clawith | grep postgresql + +# Check database password +kubectl get secret -n clawith -o yaml | grep postgres-password + +# Enter backend Pod to test connection +kubectl exec -it -n clawith deployment/clawith-backend -- /bin/bash +# Test inside Pod +nc -zv clawith-postgresql 5432 +``` + +## 🔐 Security Recommendations + +### 1. Change Default Passwords + +⚠️ **Important**: Must change all default passwords before deployment! + +```yaml +backend: + secrets: + secretKey: "Generate a random string of at least 50 characters" + jwtSecretKey: "Generate a random string of at least 32 characters" + +postgresql: + auth: + password: "Generate a strong password" # Do not use default clawith123456 +``` + +Methods to generate random passwords: +```bash +# Generate 50-character random string +openssl rand -base64 36 + +# Or use Python +python3 -c "import secrets; print(secrets.token_urlsafe(50))" + +# Generate 32-character random string +openssl rand -base64 24 +``` + +### 2. Use External Secrets + +In production environments, external Secret management is recommended: + +```bash +# Create Secret +kubectl create secret generic clawith-secrets \ + --from-literal=secret-key='your-secret-key' \ + --from-literal=jwt-secret-key='your-jwt-secret' \ + -n clawith + +# Configure in values.yaml +secrets: + create: false + existingSecret: "clawith-secrets" +``` + +### 3. Enable HTTPS + +```yaml +frontend: + ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: "clawith.yourcompany.com" + tls: + enabled: true + secretName: clawith-tls-secret +``` + +### 4. Configure Resource Limits + +```yaml +backend: + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi +``` + +### 5. Use Private Image Registry + +```yaml +global: + imageRegistry: registry.yourcompany.com/clawith + +# If authentication is required, create imagePullSecret +# kubectl create secret docker-registry regcred \ +# --docker-server=registry.yourcompany.com \ +# --docker-username=user \ +# --docker-password=password \ +# -n clawith +``` + +## 💡 Practical Tips + +### Preview Deployment Content + +Before actual deployment, preview the generated YAML: + +```bash +# Render template without installing +helm template clawith ./helm/clawith -n clawith > preview.yaml + +# Or use --dry-run +helm install clawith ./helm/clawith -n clawith --dry-run --debug +``` + +### Compare Configuration Differences + +Install helm-diff plugin to compare configuration changes: + +```bash +# Install plugin +helm plugin install https://github.com/databus23/helm-diff + +# View upgrade differences +helm diff upgrade clawith ./helm/clawith -n clawith +``` + +### Export Current Configuration + +```bash +# Export currently used values +helm get values clawith -n clawith > current-values.yaml + +# Export complete manifest +helm get manifest clawith -n clawith > current-manifest.yaml +``` + +### Update Specific Components Only + +```bash +# Update backend image version only +helm upgrade clawith ./helm/clawith -n clawith \ + --set backend.image.tag=v1.0.1 \ + --reuse-values + +# Update frontend configuration only +helm upgrade clawith ./helm/clawith -n clawith \ + --set frontend.ingress.host=new.example.com \ + --reuse-values + +# Update image registry +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=new-registry.com/project \ + --reuse-values +``` + +## 📊 Monitoring and Maintenance + +### Check Resource Usage + +```bash +# Check Pod resource usage +kubectl top pods -n clawith + +# Check Node resource usage +kubectl top nodes + +# Check PVC usage +kubectl get pvc -n clawith +``` + +### Regular Backups + +**Backup PostgreSQL Data:** + +```bash +# Export database +kubectl exec -n clawith clawith-postgresql-0 -- \ + pg_dump -U postgres clawith > backup-$(date +%Y%m%d).sql + +# Restore database +kubectl exec -i -n clawith clawith-postgresql-0 -- \ + psql -U postgres clawith < backup-20260402.sql +``` + +**Backup Helm Configuration:** + +```bash +# Backup current configuration +helm get values clawith -n clawith > backup-values-$(date +%Y%m%d).yaml +``` + +## 🎯 Comparison with Original K8s Deployment + +| Feature | Original K8s YAML | Helm Chart | +|---------|-------------------|------------| +| Configuration Management | Scattered in multiple files | Centralized in values.yaml | +| Version Control | Manual management | Automatically tracked by Helm | +| Upgrade | Apply one by one | `helm upgrade` single command | +| Rollback | Difficult | `helm rollback` single command | +| Parameterization | Manual replacement | Automatic template rendering | +| Environment Management | Copy multiple YAML files | One template + values.yaml | +| Dependency Management | Manual sequence | Automatically handled by Helm | +| Maintainability | Low | High | + +## 📚 More Information + +- **Detailed Configuration**: `helm/clawith/README.md` +- **Helm Official Documentation**: https://helm.sh/docs/ +- **Kubernetes Documentation**: https://kubernetes.io/docs/ + +## ❓ FAQ + +**Q: How to check current configuration?** +```bash +helm get values clawith -n clawith +``` + +**Q: How to update only one configuration item without affecting others?** +```bash +helm upgrade clawith ./helm/clawith -n clawith --reuse-values --set backend.image.tag=v1.0.1 +``` + +**Q: How to preserve data after uninstallation?** +```bash +# Helm uninstall does not delete PVCs by default, data is preserved +helm uninstall clawith -n clawith +# PVC still exists and can be reused during next installation +``` + +**Q: How to check why deployment failed?** +```bash +kubectl get events -n clawith --sort-by='.lastTimestamp' +kubectl logs -n clawith +helm status clawith -n clawith +``` + +**Q: How to change image registry?** +```bash +# Method 1: Modify global.imageRegistry in values.yaml +# Method 2: Use --set parameter +helm upgrade clawith ./helm/clawith -n clawith \ + --set global.imageRegistry=new-registry.com/project +``` + +**Q: How to verify if StorageClass is available?** +```bash +# Check available StorageClasses +kubectl get storageclass + +# Check StorageClass details +kubectl describe storageclass nfs-client +``` + +--- + +**Happy deploying!** 🎉 diff --git a/helm/clawith/Chart.yaml b/helm/clawith/Chart.yaml new file mode 100644 index 000000000..37437794e --- /dev/null +++ b/helm/clawith/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: clawith +description: A Helm chart for Clawith application deployment +type: application +version: 1.0.0 +appVersion: "v260331" +keywords: + - clawith + - agent + - ai +maintainers: + - name: Clawith Team +home: https://github.com/clawith/clawith diff --git a/helm/clawith/README.md b/helm/clawith/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/helm/clawith/templates/_helpers.tpl b/helm/clawith/templates/_helpers.tpl new file mode 100644 index 000000000..1c2f67f1f --- /dev/null +++ b/helm/clawith/templates/_helpers.tpl @@ -0,0 +1,138 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "clawith.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "clawith.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "clawith.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "clawith.labels" -}} +helm.sh/chart: {{ include "clawith.chart" . }} +{{ include "clawith.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "clawith.selectorLabels" -}} +app.kubernetes.io/name: {{ include "clawith.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +PostgreSQL host +*/}} +{{- define "clawith.postgresql.host" -}} +{{- if .Values.postgresql.enabled }} +{{- printf "%s-postgresql" (include "clawith.fullname" .) }} +{{- else }} +{{- .Values.postgresql.external.host }} +{{- end }} +{{- end }} + +{{/* +PostgreSQL port +*/}} +{{- define "clawith.postgresql.port" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.primary.service.port }} +{{- else }} +{{- .Values.postgresql.external.port }} +{{- end }} +{{- end }} + +{{/* +PostgreSQL database +*/}} +{{- define "clawith.postgresql.database" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.auth.database }} +{{- else }} +{{- .Values.postgresql.external.database }} +{{- end }} +{{- end }} + +{{/* +PostgreSQL username +*/}} +{{- define "clawith.postgresql.username" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.auth.username }} +{{- else }} +{{- .Values.postgresql.external.username }} +{{- end }} +{{- end }} + +{{/* +PostgreSQL password +*/}} +{{- define "clawith.postgresql.password" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.auth.password }} +{{- else }} +{{- .Values.postgresql.external.password }} +{{- end }} +{{- end }} + +{{/* +Redis host +*/}} +{{- define "clawith.redis.host" -}} +{{- if .Values.redis.enabled }} +{{- printf "%s-redis" (include "clawith.fullname" .) }} +{{- else }} +{{- .Values.redis.external.host }} +{{- end }} +{{- end }} + +{{/* +Redis port +*/}} +{{- define "clawith.redis.port" -}} +{{- if .Values.redis.enabled }} +{{- .Values.redis.service.port }} +{{- else }} +{{- .Values.redis.external.port }} +{{- end }} +{{- end }} + +{{/* +Secret name +*/}} +{{- define "clawith.secretName" -}} +{{- if .Values.secrets.create }} +{{- printf "%s-secrets" (include "clawith.fullname" .) }} +{{- else }} +{{- .Values.secrets.existingSecret }} +{{- end }} +{{- end }} + diff --git a/helm/clawith/templates/backend.yaml b/helm/clawith/templates/backend.yaml new file mode 100644 index 000000000..b0ddafc42 --- /dev/null +++ b/helm/clawith/templates/backend.yaml @@ -0,0 +1,141 @@ +{{- if .Values.backend.enabled }} +{{- if and .Values.backend.persistence.enabled (not .Values.backend.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "clawith.fullname" . }}-backend-data + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + accessModes: + - {{ .Values.backend.persistence.accessMode }} + {{- if .Values.backend.persistence.storageClass }} + storageClassName: {{ .Values.backend.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.backend.persistence.size }} +{{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "clawith.fullname" . }}-backend + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "clawith.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + labels: + {{- include "clawith.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + containers: + - name: backend + image: "{{ .Values.global.imageRegistry }}/{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + {{- if .Values.backend.hostCerts.enabled }} + lifecycle: + postStart: + exec: + command: + - /bin/sh + - '-c' + - >- + for i in `ls {{ .Values.backend.hostCerts.paths.shareCA }}/*.crt + {{ .Values.backend.hostCerts.paths.certs }}/*.crt 2>/dev/null || true`; do + [ -f "$i" ] && echo -e "`cat $i`\n" >> {{ .Values.backend.hostCerts.containerPaths.sslCertFile }} + done + {{- end }} + ports: + - containerPort: {{ .Values.backend.service.port }} + name: http + env: + - name: DATABASE_URL + value: "postgresql+asyncpg://{{ include "clawith.postgresql.username" . }}:{{ include "clawith.postgresql.password" . }}@{{ include "clawith.postgresql.host" . }}:{{ include "clawith.postgresql.port" . }}/{{ include "clawith.postgresql.database" . }}" + - name: REDIS_URL + value: "redis://{{ include "clawith.redis.host" . }}:{{ include "clawith.redis.port" . }}/{{ .Values.redis.external.database | default 0 }}" + - name: AGENT_DATA_DIR + value: {{ .Values.backend.env.agentDataDir | quote }} + - name: AGENT_TEMPLATE_DIR + value: {{ .Values.backend.env.agentTemplateDir | quote }} + {{- if .Values.backend.hostCerts.enabled }} + - name: SSL_CERT_FILE + value: {{ .Values.backend.hostCerts.containerPaths.sslCertFile | quote }} + - name: REQUESTS_CA_BUNDLE + value: {{ .Values.backend.hostCerts.containerPaths.requestsCaBundle | quote }} + - name: CURL_CA_BUNDLE + value: {{ .Values.backend.hostCerts.containerPaths.curlCaBundle | quote }} + {{- end }} + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "clawith.secretName" . }} + key: secret-key + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "clawith.secretName" . }} + key: jwt-secret-key + volumeMounts: + {{- if .Values.backend.persistence.enabled }} + - name: agent-data + mountPath: {{ .Values.backend.env.agentDataDir }} + {{- end }} + {{- if .Values.backend.hostCerts.enabled }} + - mountPath: {{ .Values.backend.hostCerts.paths.certs }} + name: clawithcerts + readOnly: true + - mountPath: {{ .Values.backend.hostCerts.paths.shareCA }} + name: clawithcerts-share + readOnly: true + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} + volumes: + {{- if .Values.backend.persistence.enabled }} + - name: agent-data + persistentVolumeClaim: + claimName: {{ .Values.backend.persistence.existingClaim | default (printf "%s-backend-data" (include "clawith.fullname" .)) }} + {{- end }} + {{- if .Values.backend.hostCerts.enabled }} + - name: clawithcerts + hostPath: + path: {{ .Values.backend.hostCerts.paths.certs }} + type: Directory + - name: clawithcerts-share + hostPath: + path: {{ .Values.backend.hostCerts.paths.shareCA }} + type: Directory + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "clawith.fullname" . }}-backend + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: {{ .Values.backend.service.type }} + selector: + {{- include "clawith.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend + ports: + - protocol: TCP + port: {{ .Values.backend.service.port }} + targetPort: http + name: http +{{- end }} diff --git a/helm/clawith/templates/frontend.yaml b/helm/clawith/templates/frontend.yaml new file mode 100644 index 000000000..5b2856085 --- /dev/null +++ b/helm/clawith/templates/frontend.yaml @@ -0,0 +1,56 @@ +{{- if .Values.frontend.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "clawith.fullname" . }}-frontend + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "clawith.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + labels: + {{- include "clawith.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: "{{ .Values.global.imageRegistry }}/{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - containerPort: {{ .Values.frontend.service.targetPort }} + name: http + env: + - name: VITE_API_URL + value: {{ .Values.frontend.env.viteApiUrl | quote }} + {{- with .Values.frontend.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "clawith.fullname" . }}-frontend + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: {{ .Values.frontend.service.type }} + selector: + {{- include "clawith.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend + ports: + - protocol: TCP + port: {{ .Values.frontend.service.port }} + targetPort: http + name: http +{{- end }} + diff --git a/helm/clawith/templates/ingress.yaml b/helm/clawith/templates/ingress.yaml new file mode 100644 index 000000000..912bca875 --- /dev/null +++ b/helm/clawith/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if and .Values.frontend.enabled .Values.frontend.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "clawith.fullname" . }}-frontend + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend + {{- with .Values.frontend.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.frontend.ingress.className }} + ingressClassName: {{ .Values.frontend.ingress.className }} + {{- end }} + {{- if .Values.frontend.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.frontend.ingress.host }} + secretName: {{ .Values.frontend.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.frontend.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "clawith.fullname" . }}-frontend + port: + number: {{ .Values.frontend.service.port }} +{{- end }} + diff --git a/helm/clawith/templates/namespace.yaml b/helm/clawith/templates/namespace.yaml new file mode 100644 index 000000000..ee337d4b8 --- /dev/null +++ b/helm/clawith/templates/namespace.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} +{{- if .Values.secrets.create }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "clawith.fullname" . }}-secrets + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} +type: Opaque +stringData: + secret-key: {{ .Values.backend.secrets.secretKey | quote }} + jwt-secret-key: {{ .Values.backend.secrets.jwtSecretKey | quote }} +{{- end }} + diff --git a/helm/clawith/templates/postgresql.yaml b/helm/clawith/templates/postgresql.yaml new file mode 100644 index 000000000..66924c139 --- /dev/null +++ b/helm/clawith/templates/postgresql.yaml @@ -0,0 +1,183 @@ +{{- if .Values.postgresql.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "clawith.fullname" . }}-postgresql + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +type: Opaque +data: + postgres-password: {{ .Values.postgresql.auth.password | b64enc | quote }} +--- +{{- if and .Values.postgresql.primary.persistence.enabled (not .Values.postgresql.primary.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "clawith.fullname" . }}-postgresql-data + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + accessModes: + - {{ .Values.postgresql.primary.persistence.accessMode }} + {{- if .Values.postgresql.primary.persistence.storageClass }} + storageClassName: {{ .Values.postgresql.primary.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgresql.primary.persistence.size }} +{{- end }} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "clawith.fullname" . }}-postgresql + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + serviceName: {{ include "clawith.fullname" . }}-postgresql-hl + replicas: 1 + selector: + matchLabels: + {{- include "clawith.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: postgresql + template: + metadata: + labels: + {{- include "clawith.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: postgresql + spec: + {{- with .Values.postgresql.primary.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: postgresql + image: "{{ .Values.postgresql.image.registry }}/{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" + imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }} + {{- with .Values.postgresql.primary.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 10 }} + {{- end }} + env: + - name: BITNAMI_DEBUG + value: "false" + - name: POSTGRESQL_PORT_NUMBER + value: "{{ .Values.postgresql.primary.service.port }}" + - name: POSTGRESQL_VOLUME_DIR + value: /bitnami/postgresql + - name: PGDATA + value: /bitnami/postgresql/data + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "clawith.fullname" . }}-postgresql + key: postgres-password + - name: POSTGRES_DB + value: {{ .Values.postgresql.auth.database | quote }} + - name: POSTGRESQL_ENABLE_LDAP + value: "no" + - name: POSTGRESQL_ENABLE_TLS + value: "no" + - name: POSTGRESQL_LOG_HOSTNAME + value: "false" + - name: POSTGRESQL_LOG_CONNECTIONS + value: "false" + - name: POSTGRESQL_LOG_DISCONNECTIONS + value: "false" + - name: POSTGRESQL_PGAUDIT_LOG_CATALOG + value: "off" + - name: POSTGRESQL_CLIENT_MIN_MESSAGES + value: error + - name: POSTGRESQL_SHARED_PRELOAD_LIBRARIES + value: pgaudit + ports: + - name: tcp-postgresql + containerPort: {{ .Values.postgresql.primary.service.port }} + livenessProbe: + exec: + command: + - /bin/sh + - '-c' + - exec pg_isready -U "{{ .Values.postgresql.auth.username }}" -d "dbname={{ .Values.postgresql.auth.database }}" -h 127.0.0.1 -p {{ .Values.postgresql.primary.service.port }} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + exec: + command: + - /bin/sh + - '-c' + - '-e' + - | + exec pg_isready -U "{{ .Values.postgresql.auth.username }}" -d "dbname={{ .Values.postgresql.auth.database }}" -h 127.0.0.1 -p {{ .Values.postgresql.primary.service.port }} + [ -f /opt/bitnami/postgresql/tmp/.initialized ] || [ -f /bitnami/postgresql/.initialized ] + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + {{- with .Values.postgresql.primary.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} + volumeMounts: + - name: dshm + mountPath: /dev/shm + {{- if .Values.postgresql.primary.persistence.enabled }} + - name: data + mountPath: /bitnami/postgresql + {{- end }} + volumes: + - name: dshm + emptyDir: + medium: Memory + {{- if .Values.postgresql.primary.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.postgresql.primary.persistence.existingClaim | default (printf "%s-postgresql-data" (include "clawith.fullname" .)) }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "clawith.fullname" . }}-postgresql + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + type: {{ .Values.postgresql.primary.service.type }} + selector: + {{- include "clawith.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: postgresql + ports: + - name: tcp-postgresql + port: {{ .Values.postgresql.primary.service.port }} + targetPort: tcp-postgresql +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "clawith.fullname" . }}-postgresql-hl + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + type: ClusterIP + clusterIP: None + selector: + {{- include "clawith.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: postgresql + ports: + - name: tcp-postgresql + port: {{ .Values.postgresql.primary.service.port }} + targetPort: tcp-postgresql +{{- end }} + diff --git a/helm/clawith/templates/redis.yaml b/helm/clawith/templates/redis.yaml new file mode 100644 index 000000000..2febfa457 --- /dev/null +++ b/helm/clawith/templates/redis.yaml @@ -0,0 +1,93 @@ +{{- if .Values.redis.enabled }} +{{- if and .Values.redis.persistence.enabled (not .Values.redis.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "clawith.fullname" . }}-redis-data + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + accessModes: + - {{ .Values.redis.persistence.accessMode }} + {{- if .Values.redis.persistence.storageClass }} + storageClassName: {{ .Values.redis.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.persistence.size }} +{{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "clawith.fullname" . }}-redis + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "clawith.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "clawith.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + containers: + - name: redis + image: "{{ .Values.redis.image.registry }}/{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.redis.image.pullPolicy }} + ports: + - name: redis + containerPort: {{ .Values.redis.service.port }} + livenessProbe: + exec: + command: + - redis-cli + - ping + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 5 + {{- with .Values.redis.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- if .Values.redis.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + {{- if .Values.redis.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ .Values.redis.persistence.existingClaim | default (printf "%s-redis-data" (include "clawith.fullname" .)) }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "clawith.fullname" . }}-redis + namespace: {{ .Values.global.namespace }} + labels: + {{- include "clawith.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: {{ .Values.redis.service.type }} + selector: + {{- include "clawith.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis + ports: + - name: tcp-redis + port: {{ .Values.redis.service.port }} + targetPort: redis +{{- end }} + diff --git a/helm/clawith/templates/secrets.yaml b/helm/clawith/templates/secrets.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/helm/clawith/templates/storageclass.yaml b/helm/clawith/templates/storageclass.yaml new file mode 100644 index 000000000..dff2be82a --- /dev/null +++ b/helm/clawith/templates/storageclass.yaml @@ -0,0 +1,17 @@ +{{- if .Values.storageClass.create }} +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: {{ .Values.storageClass.name }} + labels: + {{- include "clawith.labels" . | nindent 4 }} +provisioner: {{ .Values.storageClass.provisioner }} +reclaimPolicy: {{ .Values.storageClass.reclaimPolicy }} +volumeBindingMode: {{ .Values.storageClass.volumeBindingMode }} +allowVolumeExpansion: {{ .Values.storageClass.allowVolumeExpansion }} +{{- with .Values.storageClass.parameters }} +parameters: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} + diff --git a/helm/clawith/values.yaml b/helm/clawith/values.yaml new file mode 100644 index 000000000..e3f5b85a4 --- /dev/null +++ b/helm/clawith/values.yaml @@ -0,0 +1,228 @@ +# Default values for clawith +# This is a YAML-formatted file. + +# Global settings +global: + # Namespace to deploy to + namespace: clawith + # Image registry + imageRegistry: your docker image registry, e.g. docker.io/yourusername + +# Backend configuration +backend: + enabled: true + replicaCount: 1 + + image: + repository: clawith-backend + tag: latest + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 8000 + + # Secrets - will be used if secrets.create is true + secrets: + secretKey: "change-me-to-your-secret-key-at-least-50-characters-long" + jwtSecretKey: "change-me-to-your-jwt-secret-key-at-least-32-characters" + + # SSL certificates from host (for private/self-signed certificates) + # Only enable this if you need to mount custom CA certificates + hostCerts: + enabled: false + paths: + certs: /etc/ssl/certs + shareCA: /usr/local/share/ca-certificates + # SSL Certificate paths in container (only used when hostCerts.enabled = true) + containerPaths: + sslCertFile: /app/cacert.pem + requestsCaBundle: /app/cacert.pem + curlCaBundle: /app/cacert.pem + + # Environment variables for agent + env: + # Agent data directory + agentDataDir: /data/agents + agentTemplateDir: /app/agent_template + # Add custom environment variables here + # custom: {} + + # Persistent storage for agent data + persistence: + enabled: true + # If using existing PVC, set existingClaim + existingClaim: "" + # Storage class for dynamic provisioning + # If empty, uses default storage class + storageClass: "your storage class, e.g. nfs-client" + accessMode: ReadWriteOnce + size: 10Gi + + resources: {} + # limits: + # cpu: 2000m + # memory: 4Gi + # requests: + # cpu: 500m + # memory: 1Gi + +# Frontend configuration +frontend: + enabled: true + replicaCount: 1 + + image: + repository: clawith-frontend + tag: latest + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 80 + targetPort: 3000 + + env: + viteApiUrl: "http://clawith-backend:8000" + + ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + # cert-manager.io/cluster-issuer: "letsencrypt-prod" + # nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: your ingress host, e.g. clawith.example.com + tls: + enabled: false + secretName: clawith-tls-secret + + resources: {} + # limits: + # cpu: 500m + # memory: 512Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# PostgreSQL configuration +postgresql: + enabled: true + # Set to false if using external PostgreSQL + + image: + repository: bitnami/postgresql + registry: your docker image registry, e.g. docker.io/yourusername + tag: 15.3.0-debian-11-r7 + pullPolicy: IfNotPresent + + auth: + database: clawith + username: postgres + # Password will be base64 encoded + password: clawith123456 + + primary: + service: + type: ClusterIP + port: 5432 + + persistence: + enabled: true + # If using existing PVC, set existingClaim + existingClaim: "" + # Storage class for dynamic provisioning + storageClass: "nfs-client" + accessMode: ReadWriteOnce + size: 8Gi + + resources: + requests: + cpu: 250m + memory: 256Mi + + podSecurityContext: + fsGroup: 1001 + + containerSecurityContext: + runAsUser: 1001 + + # External PostgreSQL settings (if postgresql.enabled = false) + external: + host: "" + port: 5432 + database: clawith + username: postgres + password: "" + +# Redis configuration +redis: + enabled: true + # Set to false if using external Redis + + image: + repository: redis + registry: your docker image registry, e.g. docker.io/yourusername + tag: 7-alpine + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 6379 + + persistence: + enabled: true + # If using existing PVC, set existingClaim + existingClaim: "" + # Storage class for dynamic provisioning + storageClass: "your storage class, e.g. nfs-client" + accessMode: ReadWriteOnce + size: 2Gi + + resources: {} + # limits: + # cpu: 500m + # memory: 512Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # External Redis settings (if redis.enabled = false) + external: + host: "" + port: 6379 + database: 0 + password: "" + +# Secrets configuration +secrets: + # Create secrets or use existing + create: true + # Name of existing secret (if create is false) + existingSecret: "" + +# Storage Class configuration (optional) +# Set createStorageClass to true if you want to create a custom storage class +storageClass: + create: false + name: clawith-storage + provisioner: kubernetes.io/no-provisioner # Change to your provisioner + reclaimPolicy: Retain + volumeBindingMode: WaitForFirstConsumer + allowVolumeExpansion: true + parameters: {} + # type: gp2 + # fsType: ext4 +apiVersion: v2 +name: clawith +description: A Helm chart for Clawith application deployment +type: application +version: 1.0.0 +appVersion: "v260331" +keywords: + - clawith + - agent + - ai +maintainers: + - name: Clawith Team +home: https://github.com/clawith/clawith From 1e6a59a3bf2bff946c6f6e6ff6a3f8e8c4ea3415 Mon Sep 17 00:00:00 2001 From: zonglin zhang <953439728@qq.com> Date: Fri, 3 Apr 2026 15:11:01 +0800 Subject: [PATCH 02/10] add helm chart to support K8S deployment --- helm/QUICKSTART.md | 9 +++++++++ helm/QUICKSTART_EN.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/helm/QUICKSTART.md b/helm/QUICKSTART.md index 279dae83d..8369bec90 100644 --- a/helm/QUICKSTART.md +++ b/helm/QUICKSTART.md @@ -21,6 +21,15 @@ helm/ └── QUICKSTART.md # 本文档 ``` +## ⚠️ 重要说明 + +**K8s 部署方式限制:** +- ✅ 当前 Helm Chart **仅支持 Native Agent 部署方式** +- ❌ **不支持 OpenClaw Agent 托管模式** +- 如需使用 OpenClaw Agent 托管,请采用 Docker Compose 或其他部署方式 + +Native Agent 是 Clawith 内置的代理模式,适用于 Kubernetes 环境。OpenClaw Agent 托管模式目前仅在 Docker Compose 环境中支持。 + ## 🚀 快速开始 ### 1. 编辑配置文件 diff --git a/helm/QUICKSTART_EN.md b/helm/QUICKSTART_EN.md index 62dba0840..74782dd05 100644 --- a/helm/QUICKSTART_EN.md +++ b/helm/QUICKSTART_EN.md @@ -21,6 +21,15 @@ helm/ └── QUICKSTART.md # This Document ``` +## ⚠️ Important Notice + +**K8s Deployment Limitations:** +- ✅ Current Helm Chart **only supports Native Agent deployment mode** +- ❌ **Does NOT support OpenClaw Agent hosting mode** +- If you need to use OpenClaw Agent hosting, please use Docker Compose or other deployment methods + +Native Agent is the built-in proxy mode of Clawith, suitable for Kubernetes environments. OpenClaw Agent hosting mode is currently only supported in Docker Compose environments. + ## 🚀 Quick Start ### 1. Edit Configuration File From 34b16067ef222be6ecb89e4518a3fea38a91943c Mon Sep 17 00:00:00 2001 From: zonglin zhang <953439728@qq.com> Date: Thu, 16 Apr 2026 22:15:00 +0800 Subject: [PATCH 03/10] support share agent with specific users, extend premission control --- backend/app/api/agents.py | 84 ++++++-- backend/app/api/wecom.py | 2 +- backend/app/core/permissions.py | 5 +- backend/app/models/agent.py | 4 +- backend/app/schemas/schemas.py | 3 +- backend/app/services/agent_manager.py | 15 +- frontend/src/i18n/en.json | 21 +- frontend/src/i18n/zh.json | 12 +- frontend/src/pages/AgentCreate.tsx | 241 ++++++++++++++++++++- frontend/src/pages/AgentDetail.tsx | 296 +++++++++++++++++++++++++- frontend/src/services/api.ts | 6 + 11 files changed, 643 insertions(+), 46 deletions(-) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 0f8f1bc41..6aae8541c 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -169,6 +169,7 @@ async def list_agents( .where( (AgentPermission.scope_type == "company") | ((AgentPermission.scope_type == "user") & (AgentPermission.scope_id == current_user.id)) + | ((AgentPermission.scope_type == "private") & (AgentPermission.scope_id == current_user.id)) ) ) permitted = select(Agent).where(Agent.id.in_(permitted_ids), Agent.tenant_id == user_tenant) @@ -198,6 +199,11 @@ async def create_agent( db: AsyncSession = Depends(get_db), ): """Create a new digital employee (any authenticated user).""" + # Debug: log permission data + import logging + logger = logging.getLogger(__name__) + logger.info(f"[create_agent] Received permission data: scope_type={data.permission_scope_type}, scope_ids={data.permission_scope_ids}, access_level={data.permission_access_level}") + # Check agent creation quota from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded try: @@ -270,17 +276,28 @@ async def create_agent( # Set permissions access_level = data.permission_access_level if data.permission_access_level in ("use", "manage") else "use" - if data.permission_scope_type not in ("company", "user"): + if data.permission_scope_type not in ("company", "user", "private"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported permission_scope_type") if data.permission_scope_type == "company": db.add(AgentPermission(agent_id=agent.id, scope_type="company", access_level=access_level)) elif data.permission_scope_type == "user": if data.permission_scope_ids: - for scope_id in data.permission_scope_ids: - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=access_level)) + # Check if we have per-user access levels + if data.user_permissions and isinstance(data.user_permissions, dict): + # Per-user permissions: {user_id: access_level} + for scope_id in data.permission_scope_ids: + user_access = data.user_permissions.get(str(scope_id), access_level) + db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=user_access)) + else: + # Legacy: all users share the same access level + for scope_id in data.permission_scope_ids: + db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=access_level)) else: - # "仅自己" — insert creator as the only permitted user - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage")) + # No users specified - fallback to private (creator only) + db.add(AgentPermission(agent_id=agent.id, scope_type="private", scope_id=current_user.id, access_level="manage")) + elif data.permission_scope_type == "private": + # Private: only creator can access + db.add(AgentPermission(agent_id=agent.id, scope_type="private", scope_id=current_user.id, access_level="manage")) await db.flush() @@ -397,20 +414,26 @@ async def get_agent_permissions( perms = result.scalars().all() if not perms: - return {"scope_type": "user", "scope_ids": [], "access_level": "manage" if is_agent_creator(current_user, agent) else "use", "is_owner": is_agent_creator(current_user, agent)} + return {"scope_type": "private", "scope_ids": [], "access_level": "manage" if is_agent_creator(current_user, agent) else "use", "is_owner": is_agent_creator(current_user, agent)} scope_type = perms[0].scope_type scope_ids = [str(p.scope_id) for p in perms if p.scope_id] perm_access_level = perms[0].access_level or "use" - # Resolve names for display + # Resolve names and access levels for display scope_names = [] if scope_type == "user": - for sid in scope_ids: - r = await db.execute(select(User).where(User.id == uuid.UUID(sid))) - u = r.scalar_one_or_none() - if u: - scope_names.append({"id": sid, "name": u.display_name or u.username}) + for perm in perms: + if perm.scope_id: + sid = str(perm.scope_id) + r = await db.execute(select(User).where(User.id == perm.scope_id)) + u = r.scalar_one_or_none() + if u: + scope_names.append({ + "id": sid, + "name": u.display_name or u.username, + "access_level": perm.access_level or "use" + }) return { "scope_type": scope_type, @@ -418,6 +441,7 @@ async def get_agent_permissions( "scope_names": scope_names, "access_level": perm_access_level, "is_owner": is_agent_creator(current_user, agent), + "creator_id": str(agent.creator_id) if agent.creator_id else None, } @@ -428,17 +452,23 @@ async def update_agent_permissions( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Update agent permission scope (owner or platform_admin only).""" - agent, _access = await check_agent_access(db, current_user, agent_id) - if not is_agent_creator(current_user, agent): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner or admin can change permissions") + """Update agent permission scope (owner, admin, or users with manage access).""" + agent, access_level = await check_agent_access(db, current_user, agent_id) + + # Check if user has permission to modify + is_owner = is_agent_creator(current_user, agent) + is_admin = current_user.role in ("platform_admin", "org_admin") + has_manage_access = access_level == "manage" + + if not is_owner and not is_admin and not has_manage_access: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner, admin, or users with manage access can change permissions") scope_type = data.get("scope_type", "company") scope_ids = data.get("scope_ids", []) access_level = data.get("access_level", "use") if access_level not in ("use", "manage"): access_level = "use" - if scope_type not in ("company", "user"): + if scope_type not in ("company", "user", "private"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported scope_type") # Delete existing permissions @@ -450,11 +480,23 @@ async def update_agent_permissions( db.add(AgentPermission(agent_id=agent_id, scope_type="company", access_level=access_level)) elif scope_type == "user": if scope_ids: - for sid in scope_ids: - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=access_level)) + # Check if we have per-user access levels + user_permissions = data.get('user_permissions', None) + if user_permissions and isinstance(user_permissions, dict): + # Per-user permissions: {user_id: access_level} + for sid in scope_ids: + user_access = user_permissions.get(str(sid), access_level) + db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=user_access)) + else: + # Legacy: all users share the same access level + for sid in scope_ids: + db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=access_level)) else: - # "仅自己" - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage")) + # No users specified - fallback to private + db.add(AgentPermission(agent_id=agent_id, scope_type="private", scope_id=current_user.id, access_level="manage")) + elif scope_type == "private": + # Private: only creator can access + db.add(AgentPermission(agent_id=agent_id, scope_type="private", scope_id=current_user.id, access_level="manage")) await db.commit() return {"status": "ok"} diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 8aaa30469..19bc3549f 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -32,7 +32,7 @@ from app.models.identity import IdentityProvider, SSOScanSession from app.models.user import User from app.services.activity_logger import log_activity -from app.services.auth_provider import auth_provider_registry +from app.services.auth_registry import auth_provider_registry from app.services.channel_session import find_or_create_channel_session from app.services.channel_user_service import channel_user_service from app.services.platform_service import platform_service diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 63e551ad0..c31127a6e 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -20,7 +20,7 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) Access is granted if: 1. User is platform admin → manage 2. User is the agent creator → manage - 3. User has explicit permission (company/user scope) → from permission record + 3. User has explicit permission (company/user/private scope) → from permission record """ result = await db.execute(select(Agent).where(Agent.id == agent_id)) agent = result.scalar_one_or_none() @@ -48,6 +48,9 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) return agent, perm.access_level or "use" if perm.scope_type == "user" and perm.scope_id == user.id: return agent, perm.access_level or "use" + if perm.scope_type == "private" and perm.scope_id == user.id: + # Private scope: only the specified user (creator) can access + return agent, perm.access_level or "manage" raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent") diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 8cb129f7a..b42ab5faa 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -137,10 +137,10 @@ class AgentPermission(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) agent_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False) scope_type: Mapped[str] = mapped_column( - Enum("company", "department", "user", name="permission_scope_enum"), + Enum("company", "department", "user", "private", name="permission_scope_enum"), nullable=False, ) - # scope_id: null for company, user_id for user scope + # scope_id: null for company, user_id for user/private scope scope_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # access_level: 'use' = task/chat/tool/skill/workspace only, 'manage' = full access access_level: Mapped[str] = mapped_column(String(20), default="use", nullable=False) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 3870392b9..93938e4b5 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -216,9 +216,10 @@ class AgentCreate(BaseModel): primary_model_id: uuid.UUID | None = None fallback_model_id: uuid.UUID | None = None # Permissions - permission_scope_type: str = "company" # company | user + permission_scope_type: str = "company" # company | user | private permission_scope_ids: list[uuid.UUID] = [] permission_access_level: str = "use" # use | manage + user_permissions: dict[str, str] | None = None # {user_id: access_level} for per-user permissions # Target tenant (admin-only override; otherwise ignored) tenant_id: uuid.UUID | None = None # Template diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index 9f4c4a73c..af2bffb23 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -25,9 +25,18 @@ class AgentManager: def __init__(self): try: - self.docker_client = docker.from_env() - except DockerException: - logger.warning("Docker not available — agent containers will not be managed") + # Set a timeout to avoid hanging if Docker daemon is unresponsive + import os + if os.getenv('SKIP_DOCKER', 'false').lower() == 'true': + logger.info("Docker skipped via SKIP_DOCKER environment variable") + self.docker_client = None + else: + self.docker_client = docker.from_env() + # Quick health check - ping Docker daemon + self.docker_client.ping() + logger.info("Docker connected successfully") + except Exception as e: + logger.warning(f"Docker not available ({e}) — agent containers will not be managed") self.docker_client = None def _agent_dir(self, agent_id: uuid.UUID) -> Path: diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a06b0ea7b..c7ddf0ecc 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -496,8 +496,12 @@ "description": "Control who can see and interact with this agent. Only the creator or admin can change this.", "companyWide": "Company-wide", "companyWideDesc": "All users in the organization can use this agent", - "onlyMe": "Only Me", + "specificUsers": "Specific Users", + "specificUsersDesc": "Only selected users can use this agent", + "onlyMe": "Private", "onlyMeDesc": "Only the creator can use this agent", + "selectUsers": "Select Users", + "selectedCount": "{{count}} users selected", "defaultAccess": "Default Access Level", "useAccess": "Use", "useAccessDesc": "Task, Chat, Tools, Skills, Workspace", @@ -811,10 +815,19 @@ "title": "Permissions", "companyWide": "Company-wide", "companyWideDesc": "Everyone can use this digital employee", + "specificUsers": "Specific Users", + "specificUsersDesc": "Only selected users can use this agent", "department": "Department", "departmentDesc": "Only selected department members can use", - "selfOnly": "Self Only", - "selfOnlyDesc": "Only the creator can use" + "selfOnly": "Private", + "selfOnlyDesc": "Only the creator can use", + "selectUsers": "Select Users", + "selectedCount": "{{count}} users selected", + "accessLevel": "Default Access Level", + "useLevel": "Use", + "useDesc": "Can use Task, Chat, Tools, Skills, Workspace", + "manageLevel": "Manage", + "manageDesc": "Full access including Settings, Mind, Relationships" }, "step5": { "title": "Feishu Bot Configuration (Optional)", @@ -1463,6 +1476,8 @@ "confirm": "Confirm", "delete": "Delete", "search": "Search", + "searchPlaceholder": "Search by name or email...", + "noSearchResults": "No users found matching your search", "loading": "Loading...", "noData": "No data", "error": "Error", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 1d96737a2..467650aa7 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -527,8 +527,12 @@ "description": "控制谁可以查看和与这个数字员工互动。只有创建者或管理员可以更改此设置。", "companyWide": "全公司", "companyWideDesc": "组织中的所有用户都可以使用此数字员工", - "onlyMe": "仅我可见", + "specificUsers": "指定用户", + "specificUsersDesc": "只有选定的用户可以使用此智能体", + "onlyMe": "仅自己", "onlyMeDesc": "只有创建者可以使用此数字员工", + "selectUsers": "选择用户", + "selectedCount": "已选择 {{count}} 个用户", "defaultAccess": "默认访问级别", "useAccess": "使用", "useAccessDesc": "任务、聊天、工具、技能、工作区", @@ -920,10 +924,14 @@ "title": "权限设置", "companyWide": "全公司可见", "companyWideDesc": "所有人都可以使用此数字员工", + "specificUsers": "指定用户", + "specificUsersDesc": "只有选定的用户可以使用此智能体", "department": "指定部门", "departmentDesc": "仅选定部门成员可使用", "selfOnly": "仅自己", "selfOnlyDesc": "仅创建者本人可使用", + "selectUsers": "选择用户", + "selectedCount": "已选择 {{count}} 个用户", "accessLevel": "默认访问级别", "useLevel": "使用", "useDesc": "可以使用任务、聊天、工具、技能、工作区", @@ -1553,6 +1561,8 @@ "confirm": "确认", "delete": "删除", "search": "搜索", + "searchPlaceholder": "按用户名或邮箱搜索...", + "noSearchResults": "未找到匹配的用户", "loading": "加载中...", "noData": "暂无数据", "error": "出错了", diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 8816eb9a4..8c6a370a1 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -81,6 +81,8 @@ export default function AgentCreate() { fallback_model_id: '' as string, permission_scope_type: 'company', permission_access_level: 'use', + // Store user permissions as a map: { userId: accessLevel } + permission_user_access: {} as Record, template_id: '' as string, max_tokens_per_day: '', max_tokens_per_month: '', @@ -106,6 +108,31 @@ export default function AgentCreate() { queryFn: skillApi.list, }); + // Fetch current user info + const { data: currentUser } = useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => import('../services/api').then(m => m.authApi.me()), + }); + + // Fetch users for permission selection + const { data: users = [], isLoading, error: usersError } = useQuery({ + queryKey: ['users', currentTenant], + queryFn: () => { + console.log('[AgentCreate] Fetching users, currentTenant:', currentTenant); + return enterpriseApi.users(currentTenant || undefined); + }, + enabled: form.permission_scope_type === 'user', + }); + + // Debug: log when permission scope changes + useEffect(() => { + console.log('[AgentCreate] permission_scope_type changed to:', form.permission_scope_type); + console.log('[AgentCreate] currentTenant:', currentTenant); + }, [form.permission_scope_type, currentTenant]); + + // Search state for user selector + const [userSearchKeyword, setUserSearchKeyword] = useState(''); + // Auto-select default skills useEffect(() => { if (globalSkills.length > 0) { @@ -250,6 +277,16 @@ export default function AgentCreate() { if (step === 0 || agentType === 'openclaw') { if (!validateStep0()) return; } + + // Debug: log permission data + console.log('[AgentCreate] Creating agent with permissions:', { + permission_scope_type: form.permission_scope_type, + permission_user_access: form.permission_user_access, + }); + + // Convert permission_user_access to scope_ids for backend + const scopeIds = Object.keys(form.permission_user_access); + createMutation.mutate({ name: form.name, agent_type: agentType, @@ -260,10 +297,12 @@ export default function AgentCreate() { fallback_model_id: agentType === 'native' ? (form.fallback_model_id || undefined) : undefined, template_id: form.template_id || undefined, permission_scope_type: form.permission_scope_type, + permission_scope_ids: scopeIds.length > 0 ? scopeIds : undefined, max_tokens_per_day: form.max_tokens_per_day ? Number(form.max_tokens_per_day) : undefined, max_tokens_per_month: form.max_tokens_per_month ? Number(form.max_tokens_per_month) : undefined, skill_ids: agentType === 'native' ? form.skill_ids : [], - permission_access_level: form.permission_access_level, + // Pass per-user access levels + user_permissions: scopeIds.length > 0 ? form.permission_user_access : undefined, tenant_id: currentTenant || undefined, }); }; @@ -357,7 +396,59 @@ For humans, the message is delivered via their available channel (e.g. Feishu).` ", "result": ""}\n\n### 3. Send a message to someone\nTo proactively contact a person or agent, make an HTTP POST request:\n- URL: ${window.location.origin}/api/gateway/send-message\n- Header: X-Api-Key: ${createdApiKey}\n- Header: Content-Type: application/json\n- Body: {"target": "", "content": ""}\n\nThe system auto-detects the best channel. For agents, the reply appears in your next poll.\nFor humans, the message is delivered via their available channel (e.g. Feishu).`} + textToCopy={`Please create a skill file at skills/clawith_sync.md with the following content, then add "Check Clawith inbox using the clawith_sync skill and process any pending messages" to your HEARTBEAT.md: + +--- +name: clawith_sync +description: Sync with Clawith platform — check inbox, submit results, and send messages. +--- + +# Clawith Sync + +## When to use +Check for new messages from the Clawith platform during every heartbeat cycle. +You can also proactively send messages to people and agents in your relationships. + +## Instructions + +### 1. Check inbox +Make an HTTP GET request: +- URL: ${window.location.origin}/api/gateway/poll +- Header: X-Api-Key: ${createdApiKey} + +The response contains a \`messages\` array. Each message includes: +- \`id\` — unique message ID (use this for reporting) +- \`content\` — the message text +- \`sender_user_name\` — name of the Clawith user who sent it +- \`sender_user_id\` — unique ID of the sender +- \`conversation_id\` — the conversation this message belongs to +- \`history\` — array of previous messages in this conversation for context + +The response also contains a \`relationships\` array describing your colleagues: +- \`name\` — the person or agent name +- \`type\` — "human" or "agent" +- \`role\` — relationship type (e.g. collaborator, supervisor) +- \`channels\` — available communication channels (e.g. ["feishu"], ["agent"]) + +**IMPORTANT**: Use the \`history\` array to understand conversation context before replying. +Different \`sender_user_name\` values mean different people — address them accordingly. + +### 2. Report results +For each completed message, make an HTTP POST request: +- URL: ${window.location.origin}/api/gateway/report +- Header: X-Api-Key: ${createdApiKey} +- Header: Content-Type: application/json +- Body: {"message_id": "", "result": ""} + +### 3. Send a message to someone +To proactively contact a person or agent, make an HTTP POST request: +- URL: ${window.location.origin}/api/gateway/send-message +- Header: X-Api-Key: ${createdApiKey} +- Header: Content-Type: application/json +- Body: {"target": "", "content": ""} + +The system auto-detects the best channel. For agents, the reply appears in your next poll. +For humans, the message is delivered via their available channel (e.g. Feishu).`} label={t('common.copy', 'Copy')} copiedLabel="Copied" /> @@ -760,7 +851,8 @@ For humans, the message is delivered via their available channel (e.g. Feishu).`
{[ { value: 'company', label: t('wizard.step4.companyWide'), desc: t('wizard.step4.companyWideDesc') }, - { value: 'user', label: t('wizard.step4.selfOnly'), desc: t('wizard.step4.selfOnlyDesc') }, + { value: 'user', label: t('wizard.step4.specificUsers'), desc: t('wizard.step4.specificUsersDesc') }, + { value: 'private', label: t('wizard.step4.selfOnly'), desc: t('wizard.step4.selfOnlyDesc') }, ].map((scope) => (
)} + + {/* User Selection — for specific users scope */} + {form.permission_scope_type === 'user' && ( +
+ + + {/* Debug info */} +
+ Debug: isLoading={String(isLoading)}, usersCount={(users as any[]).length}, error={usersError ? 'Yes' : 'No'} +
+ + {/* Search Input */} +
+ setUserSearchKeyword(e.target.value)} + className="input" + style={{ width: '100%', fontSize: '13px' }} + /> +
+ +
+ {(users as any[]) + // Filter out current user (creator already has manage permission) + .filter((user: any) => user.id !== currentUser?.id) + .filter((user: any) => { + if (!userSearchKeyword.trim()) return true; + const keyword = userSearchKeyword.toLowerCase(); + const name = (user.display_name || user.username || '').toLowerCase(); + const email = (user.email || '').toLowerCase(); + return name.includes(keyword) || email.includes(keyword); + }) + .map((user: any) => { + const isSelected = user.id in form.permission_user_access; + return ( + + ); + })} + {(users as any[]) + .filter((user: any) => user.id !== currentUser?.id) + .filter((user: any) => { + if (!userSearchKeyword.trim()) return true; + const keyword = userSearchKeyword.toLowerCase(); + const name = (user.display_name || user.username || '').toLowerCase(); + const email = (user.email || '').toLowerCase(); + return name.includes(keyword) || email.includes(keyword); + }).length === 0 && ( +
+ {userSearchKeyword.trim() + ? t('common.noSearchResults', 'No users found matching your search') + : t('common.noData', 'No users found') + } +
+ )} +
+ {Object.keys(form.permission_user_access).length > 0 && ( +
+ {t('wizard.step4.selectedCount', '{{count}} users selected').replace('{{count}}', String(Object.keys(form.permission_user_access).length))} +
+ )} +
+ )} )} diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 5c2168dbb..dc34a7baa 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -5360,7 +5360,8 @@ function AgentDetailInner() { {(() => { const scopeLabels: Record = { company: '🏢 ' + t('agent.settings.perm.companyWide', 'Company-wide'), - user: '👤 ' + t('agent.settings.perm.onlyMe', 'Only Me'), + user: '👥 ' + t('agent.settings.perm.specificUsers', 'Specific Users'), + private: '🔒 ' + t('agent.settings.perm.onlyMe', 'Only Me'), }; const handleScopeChange = async (newScope: string) => { @@ -5368,7 +5369,11 @@ function AgentDetailInner() { await fetchAuth(`/agents/${id}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scope_type: newScope, scope_ids: [], access_level: permData?.access_level || 'use' }), + body: JSON.stringify({ + scope_type: newScope, + scope_ids: newScope === 'user' ? permData?.scope_ids || [] : [], + access_level: permData?.access_level || 'use' + }), }); queryClient.invalidateQueries({ queryKey: ['agent-permissions', id] }); queryClient.invalidateQueries({ queryKey: ['agent', id] }); @@ -5394,6 +5399,8 @@ function AgentDetailInner() { const isOwner = permData?.is_owner ?? false; const currentScope = permData?.scope_type || 'company'; const currentAccessLevel = permData?.access_level || 'use'; + // Can manage permissions if owner OR has manage access level + const canManagePermissions = isOwner || currentAccessLevel === 'manage'; return (
@@ -5404,7 +5411,7 @@ function AgentDetailInner() { {/* Scope Selection */}
- {(['company', 'user'] as const).map((scope) => ( + {(['company', 'user', 'private'] as const).map((scope) => (
@@ -5444,7 +5452,7 @@ function AgentDetailInner() {
{/* Access Level for company scope */} - {currentScope === 'company' && isOwner && ( + {currentScope === 'company' && canManagePermissions && (
)} + {/* User Selection — for specific users scope */} + {currentScope === 'user' && canManagePermissions && ( + { + // This will be implemented in the next step + }} + creatorId={permData?.creator_id || agent?.creator_id} + userPermissions={ + // Convert scope_names to { userId: accessLevel } map + permData?.scope_names?.reduce((acc: Record, s: any) => { + acc[s.id] = s.access_level || 'use'; + return acc; + }, {} as Record) + } + /> + )} + {currentScope !== 'company' && permData?.scope_names?.length > 0 && (
{t('agent.settings.perm.currentAccess', 'Current access')}:{' '} @@ -5487,7 +5514,7 @@ function AgentDetailInner() {
)} - {!isOwner && ( + {!canManagePermissions && (
{t('agent.settings.perm.readOnly', 'Only the creator or admin can change permissions')}
@@ -5708,7 +5735,23 @@ function AgentDetailInner() { setFileEditing(true); setFileDraft(''); } else if (action === 'newSkill') { - const template = `---\nname: ${value}\ndescription: Describe what this skill does\n---\n\n# ${value}\n\n## Overview\nDescribe the purpose and when to use this skill.\n\n## Process\n1. Step one\n2. Step two\n\n## Output Format\nDescribe the expected output format.\n`; + const template = `--- +name: ${value} +description: Describe what this skill does +--- + +# ${value} + +## Overview +Describe the purpose and when to use this skill. + +## Process +1. Step one +2. Step two + +## Output Format +Describe the expected output format. +`; await fileApi.write(id!, `skills/${value}/SKILL.md`, template); queryClient.invalidateQueries({ queryKey: ['files', id, 'skills'] }); setViewingFile(`skills/${value}/SKILL.md`); @@ -5864,3 +5907,236 @@ export default function AgentDetailWithErrorBoundary() { ); } + +// User Selector Component for agent permissions +function UserSelector({ + agentId, + selectedUserIds, + onUsersChange, + creatorId, + userPermissions // Add per-user permissions: { userId: accessLevel } +}: { + agentId: string; + selectedUserIds: string[]; + onUsersChange: (userIds: string[]) => void; + creatorId?: string; + userPermissions?: Record; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [currentTenant] = useState(() => localStorage.getItem('current_tenant_id')); + const [saving, setSaving] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(''); + + // Fetch current user + const { data: currentUser } = useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => import('../services/api').then(m => m.authApi.me()), + }); + + // Fetch users + const { data: users = [], isLoading } = useQuery({ + queryKey: ['users', currentTenant], + queryFn: () => enterpriseApi.users(currentTenant || undefined), + }); + + const handleToggleUser = async (userId: string, checked: boolean) => { + const newSelectedIds = checked + ? [...selectedUserIds, userId] + : selectedUserIds.filter(id => id !== userId); + + // Preserve existing permissions or set default + const newUserPermissions = { ...userPermissions }; + if (checked) { + newUserPermissions[userId] = newUserPermissions[userId] || 'use'; + } else { + delete newUserPermissions[userId]; + } + + setSaving(true); + try { + await fetchAuth(`/agents/${agentId}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scope_type: 'user', + scope_ids: newSelectedIds, + user_permissions: newUserPermissions + }), + }); + onUsersChange(newSelectedIds); + queryClient.invalidateQueries({ queryKey: ['agent-permissions', agentId] }); + } catch (e) { + console.error('Failed to update permissions', e); + } finally { + setSaving(false); + } + }; + + const handleAccessLevelChange = async (userId: string, accessLevel: string) => { + // Cannot change creator's permission + if (userId === creatorId) { + return; + } + + setSaving(true); + try { + const newUserPermissions = { ...userPermissions, [userId]: accessLevel }; + await fetchAuth(`/agents/${agentId}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scope_type: 'user', + scope_ids: selectedUserIds, + user_permissions: newUserPermissions + }), + }); + queryClient.invalidateQueries({ queryKey: ['agent-permissions', agentId] }); + } catch (e) { + console.error('Failed to update access level', e); + } finally { + setSaving(false); + } + }; + + if (isLoading) { + return ( +
+ {t('common.loading', 'Loading...')} +
+ ); + } + + return ( +
+ + + {/* Search Input */} +
+ setSearchKeyword(e.target.value)} + className="input" + style={{ width: '100%', fontSize: '13px' }} + /> +
+ +
+ {(users as any[]) + // Filter out current user and creator (they already have manage permission) + .filter((user: any) => user.id !== currentUser?.id && user.id !== creatorId) + .filter((user: any) => { + if (!searchKeyword.trim()) return true; + const keyword = searchKeyword.toLowerCase(); + const name = (user.display_name || user.username || '').toLowerCase(); + const email = (user.email || '').toLowerCase(); + return name.includes(keyword) || email.includes(keyword); + }) + .map((user: any) => { + const isSelected = selectedUserIds.includes(user.id); + const isCreator = user.id === creatorId; + const accessLevel = userPermissions?.[user.id] || 'use'; + + return ( +
+ {/* Checkbox for selecting/deselecting user */} + handleToggleUser(user.id, e.target.checked)} + style={{ accentColor: 'var(--accent-primary)', cursor: isCreator ? 'not-allowed' : 'pointer' }} + title={isCreator ? t('agent.settings.perm.cannotChangeCreator', 'Cannot change creator permission') : ''} + /> +
+
+ {user.display_name || user.username} + {isCreator && ( + + {t('agent.settings.perm.creator', '(Creator)')} + + )} +
+ {user.email && ( +
+ {user.email} +
+ )} +
+ {/* Access level selector - only show if user is selected */} + {isSelected && ( + + )} +
+ ); + })} + {(users as any[]) + .filter((user: any) => user.id !== currentUser?.id && user.id !== creatorId) + .filter((user: any) => { + if (!searchKeyword.trim()) return true; + const keyword = searchKeyword.toLowerCase(); + const name = (user.display_name || user.username || '').toLowerCase(); + const email = (user.email || '').toLowerCase(); + return name.includes(keyword) || email.includes(keyword); + }).length === 0 && ( +
+ {searchKeyword.trim() + ? t('common.noSearchResults', 'No users found matching your search') + : t('common.noData', 'No users found') + } +
+ )} +
+ {selectedUserIds.length > 0 && ( +
+ {t('agent.settings.perm.selectedCount', '{{count}} users selected').replace('{{count}}', String(selectedUserIds.length))} +
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ad94c2def..8857e8b65 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -341,6 +341,12 @@ export const enterpriseApi = { return request(`/enterprise/llm-models${tid ? `?tenant_id=${tid}` : ''}`); }, templates: () => request('/agents/templates'), + + // Users (for permission selection) + users: (tenantId?: string) => { + const tid = tenantId || localStorage.getItem('current_tenant_id'); + return request(`/org/users${tid ? `?tenant_id=${tid}` : ''}`); + }, // Enterprise Knowledge Base kbFiles: (path: string = '') => From c460661fcc926a0f9fc2a05433a77108bbae841d Mon Sep 17 00:00:00 2001 From: zonglin zhang <953439728@qq.com> Date: Sat, 18 Apr 2026 11:19:06 +0800 Subject: [PATCH 04/10] support share agent with specific users, extend premission control --- backend/app/api/agents.py | 45 ++-- backend/app/core/permissions.py | 7 +- backend/app/models/agent.py | 4 +- backend/app/schemas/schemas.py | 2 +- frontend/src/i18n/en.json | 10 +- frontend/src/i18n/zh.json | 10 +- frontend/src/pages/AgentCreate.tsx | 6 +- frontend/src/pages/AgentDetail.tsx | 403 +++++++++++++++++++---------- 8 files changed, 324 insertions(+), 163 deletions(-) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 6aae8541c..4e8d48aba 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -169,7 +169,7 @@ async def list_agents( .where( (AgentPermission.scope_type == "company") | ((AgentPermission.scope_type == "user") & (AgentPermission.scope_id == current_user.id)) - | ((AgentPermission.scope_type == "private") & (AgentPermission.scope_id == current_user.id)) + | ((AgentPermission.scope_type == "user_group") & (AgentPermission.scope_id == current_user.id)) ) ) permitted = select(Agent).where(Agent.id.in_(permitted_ids), Agent.tenant_id == user_tenant) @@ -276,28 +276,30 @@ async def create_agent( # Set permissions access_level = data.permission_access_level if data.permission_access_level in ("use", "manage") else "use" - if data.permission_scope_type not in ("company", "user", "private"): + if data.permission_scope_type not in ("company", "user", "user_group"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported permission_scope_type") + + # Validate user_group requires at least one user + if data.permission_scope_type == "user_group" and not data.permission_scope_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user") + if data.permission_scope_type == "company": db.add(AgentPermission(agent_id=agent.id, scope_type="company", access_level=access_level)) elif data.permission_scope_type == "user": + # User: only creator can access + db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage")) + elif data.permission_scope_type == "user_group": if data.permission_scope_ids: # Check if we have per-user access levels if data.user_permissions and isinstance(data.user_permissions, dict): # Per-user permissions: {user_id: access_level} for scope_id in data.permission_scope_ids: user_access = data.user_permissions.get(str(scope_id), access_level) - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=user_access)) + db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=user_access)) else: # Legacy: all users share the same access level for scope_id in data.permission_scope_ids: - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=access_level)) - else: - # No users specified - fallback to private (creator only) - db.add(AgentPermission(agent_id=agent.id, scope_type="private", scope_id=current_user.id, access_level="manage")) - elif data.permission_scope_type == "private": - # Private: only creator can access - db.add(AgentPermission(agent_id=agent.id, scope_type="private", scope_id=current_user.id, access_level="manage")) + db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=access_level)) await db.flush() @@ -414,7 +416,7 @@ async def get_agent_permissions( perms = result.scalars().all() if not perms: - return {"scope_type": "private", "scope_ids": [], "access_level": "manage" if is_agent_creator(current_user, agent) else "use", "is_owner": is_agent_creator(current_user, agent)} + return {"scope_type": "user", "scope_ids": [], "access_level": "manage" if is_agent_creator(current_user, agent) else "use", "is_owner": is_agent_creator(current_user, agent)} scope_type = perms[0].scope_type scope_ids = [str(p.scope_id) for p in perms if p.scope_id] @@ -422,7 +424,7 @@ async def get_agent_permissions( # Resolve names and access levels for display scope_names = [] - if scope_type == "user": + if scope_type == "user_group": for perm in perms: if perm.scope_id: sid = str(perm.scope_id) @@ -468,8 +470,12 @@ async def update_agent_permissions( access_level = data.get("access_level", "use") if access_level not in ("use", "manage"): access_level = "use" - if scope_type not in ("company", "user", "private"): + if scope_type not in ("company", "user", "user_group"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported scope_type") + + # Validate user_group requires at least one user + if scope_type == "user_group" and not scope_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user") # Delete existing permissions from sqlalchemy import delete as sql_delete @@ -479,6 +485,9 @@ async def update_agent_permissions( if scope_type == "company": db.add(AgentPermission(agent_id=agent_id, scope_type="company", access_level=access_level)) elif scope_type == "user": + # User: only creator can access + db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage")) + elif scope_type == "user_group": if scope_ids: # Check if we have per-user access levels user_permissions = data.get('user_permissions', None) @@ -486,17 +495,11 @@ async def update_agent_permissions( # Per-user permissions: {user_id: access_level} for sid in scope_ids: user_access = user_permissions.get(str(sid), access_level) - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=user_access)) + db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=user_access)) else: # Legacy: all users share the same access level for sid in scope_ids: - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=access_level)) - else: - # No users specified - fallback to private - db.add(AgentPermission(agent_id=agent_id, scope_type="private", scope_id=current_user.id, access_level="manage")) - elif scope_type == "private": - # Private: only creator can access - db.add(AgentPermission(agent_id=agent_id, scope_type="private", scope_id=current_user.id, access_level="manage")) + db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=access_level)) await db.commit() return {"status": "ok"} diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index c31127a6e..e51c3ba1b 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -47,10 +47,11 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) if perm.scope_type == "company": return agent, perm.access_level or "use" if perm.scope_type == "user" and perm.scope_id == user.id: - return agent, perm.access_level or "use" - if perm.scope_type == "private" and perm.scope_id == user.id: - # Private scope: only the specified user (creator) can access + # User scope: only the creator can access return agent, perm.access_level or "manage" + if perm.scope_type == "user_group" and perm.scope_id == user.id: + # User group scope: specific users can access + return agent, perm.access_level or "use" raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent") diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index b42ab5faa..990375c59 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -137,10 +137,10 @@ class AgentPermission(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) agent_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False) scope_type: Mapped[str] = mapped_column( - Enum("company", "department", "user", "private", name="permission_scope_enum"), + Enum("company", "department", "user", "user_group", name="permission_scope_enum"), nullable=False, ) - # scope_id: null for company, user_id for user/private scope + # scope_id: null for company, user_id for user/user_group scope scope_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # access_level: 'use' = task/chat/tool/skill/workspace only, 'manage' = full access access_level: Mapped[str] = mapped_column(String(20), default="use", nullable=False) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 93938e4b5..a59afc617 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -216,7 +216,7 @@ class AgentCreate(BaseModel): primary_model_id: uuid.UUID | None = None fallback_model_id: uuid.UUID | None = None # Permissions - permission_scope_type: str = "company" # company | user | private + permission_scope_type: str = "company" # company | user (only me) | user_group (specific users) permission_scope_ids: list[uuid.UUID] = [] permission_access_level: str = "use" # use | manage user_permissions: dict[str, str] | None = None # {user_id: access_level} for per-user permissions diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index c7ddf0ecc..20698da18 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -506,8 +506,16 @@ "useAccess": "Use", "useAccessDesc": "Task, Chat, Tools, Skills, Workspace", "manageAccess": "Manage", - "manageAccessDesc": "Full access including Settings, Mind, Relationships" + "manageAccessDesc": "Full access including Settings, Mind, Relationships", + "selectAtLeastOneUser": "Please select at least one user before choosing \"Specific Users\" permission", + "userGroupAccessHint": "All selected users will have this access level", + "canUse": "Can Use", + "canManage": "Can Manage", + "creator": "(Creator)", + "cannotChangeCreator": "Cannot change creator permission" }, + "accessDenied": "Access Denied", + "accessDeniedDesc": "You do not have permission to view or modify this agent's settings. Please contact the agent creator or administrator.", "timezone": { "label": "Timezone", "description": "Set the timezone for scheduled tasks and time-based triggers", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 467650aa7..6a68bfa2c 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -537,8 +537,16 @@ "useAccess": "使用", "useAccessDesc": "任务、聊天、工具、技能、工作区", "manageAccess": "管理", - "manageAccessDesc": "完全访问权限,包括设置、思维、关系" + "manageAccessDesc": "完全访问权限,包括设置、思维、关系", + "selectAtLeastOneUser": "请选择至少一个用户后再选择“指定用户”权限", + "userGroupAccessHint": "所有选定的用户将拥有此访问级别", + "canUse": "可使用", + "canManage": "可管理", + "creator": "(创建人)", + "cannotChangeCreator": "无法修改创建人的权限" }, + "accessDenied": "无权访问", + "accessDeniedDesc": "您没有权限查看或修改此数字员工的设置。请联系数字员工创建人或管理员。", "tools": { "platformTools": "平台预置工具", "agentInstalled": "数字员工自行安装的工具", diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 8c6a370a1..3e9dd1a5f 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -851,8 +851,8 @@ For humans, the message is delivered via their available channel (e.g. Feishu).`
{[ { value: 'company', label: t('wizard.step4.companyWide'), desc: t('wizard.step4.companyWideDesc') }, - { value: 'user', label: t('wizard.step4.specificUsers'), desc: t('wizard.step4.specificUsersDesc') }, - { value: 'private', label: t('wizard.step4.selfOnly'), desc: t('wizard.step4.selfOnlyDesc') }, + { value: 'user', label: t('wizard.step4.selfOnly'), desc: t('wizard.step4.selfOnlyDesc') }, + { value: 'user_group', label: t('wizard.step4.specificUsers'), desc: t('wizard.step4.specificUsersDesc') }, ].map((scope) => (
+ + ); + })() : ( + // No permission - show access denied +
+
🔒
+

{t('agent.settings.accessDenied', 'Access Denied')}

+

+ {t('agent.settings.accessDeniedDesc', 'You do not have permission to view or modify this agent\'s settings. Please contact the agent creator or administrator.')} +

+
+ ) + )} + void; + onUserPermissionChange?: (userId: string, accessLevel: string) => void; creatorId?: string; userPermissions?: Record; }) { @@ -5940,62 +6127,17 @@ function UserSelector({ queryFn: () => enterpriseApi.users(currentTenant || undefined), }); - const handleToggleUser = async (userId: string, checked: boolean) => { + const handleToggleUser = (userId: string, checked: boolean) => { const newSelectedIds = checked ? [...selectedUserIds, userId] : selectedUserIds.filter(id => id !== userId); - // Preserve existing permissions or set default - const newUserPermissions = { ...userPermissions }; - if (checked) { - newUserPermissions[userId] = newUserPermissions[userId] || 'use'; - } else { - delete newUserPermissions[userId]; - } - - setSaving(true); - try { - await fetchAuth(`/agents/${agentId}/permissions`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - scope_type: 'user', - scope_ids: newSelectedIds, - user_permissions: newUserPermissions - }), - }); - onUsersChange(newSelectedIds); - queryClient.invalidateQueries({ queryKey: ['agent-permissions', agentId] }); - } catch (e) { - console.error('Failed to update permissions', e); - } finally { - setSaving(false); - } + onUsersChange(newSelectedIds); }; - const handleAccessLevelChange = async (userId: string, accessLevel: string) => { - // Cannot change creator's permission - if (userId === creatorId) { - return; - } - - setSaving(true); - try { - const newUserPermissions = { ...userPermissions, [userId]: accessLevel }; - await fetchAuth(`/agents/${agentId}/permissions`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - scope_type: 'user', - scope_ids: selectedUserIds, - user_permissions: newUserPermissions - }), - }); - queryClient.invalidateQueries({ queryKey: ['agent-permissions', agentId] }); - } catch (e) { - console.error('Failed to update access level', e); - } finally { - setSaving(false); + const handleUserAccessLevelChange = (userId: string, accessLevel: string) => { + if (onUserPermissionChange) { + onUserPermissionChange(userId, accessLevel); } }; @@ -6061,14 +6203,13 @@ function UserSelector({ border: isSelected ? '1px solid var(--accent-primary)' : '1px solid transparent', marginBottom: '4px', transition: 'all 0.15s', - opacity: saving ? 0.6 : 1, }} > {/* Checkbox for selecting/deselecting user */} handleToggleUser(user.id, e.target.checked)} style={{ accentColor: 'var(--accent-primary)', cursor: isCreator ? 'not-allowed' : 'pointer' }} title={isCreator ? t('agent.settings.perm.cannotChangeCreator', 'Cannot change creator permission') : ''} @@ -6097,9 +6238,9 @@ function UserSelector({ {isSelected && (