Compare commits
9 Commits
acee825b14
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f90cf0f0d | |||
| b88fe24aab | |||
| 96d42ee3e1 | |||
| 4441f58299 | |||
| 49b92e66c3 | |||
| 28ecb2e636 | |||
| 87eaaa564b | |||
| 7d9545f827 | |||
| 7f238a3168 |
79
.github/PROJECT_STRUCTURE.md
vendored
Normal file
79
.github/PROJECT_STRUCTURE.md
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
# Project Structure
|
||||
|
||||
## 📁 Directory Layout
|
||||
|
||||
```
|
||||
ocdp-go/
|
||||
├── backend/ # Go 后端
|
||||
│ ├── cmd/api/ # 主程序入口
|
||||
│ ├── internal/
|
||||
│ │ ├── handlers/ # HTTP handlers
|
||||
│ │ ├── graphql/ # GraphQL schema & resolvers
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── storage/ # 存储层(JSON)
|
||||
│ │ └── auth/ # 认证
|
||||
│ └── data/ # 数据存储(JSON文件)
|
||||
│
|
||||
└── frontend/ # React 前端
|
||||
└── src/
|
||||
├── app/ # 应用层
|
||||
│ ├── providers/ # 全局 Provider
|
||||
│ ├── routes/ # 路由配置
|
||||
│ └── constants/ # 导航配置
|
||||
│
|
||||
├── features/ # 功能模块(按类别)
|
||||
│ ├── configuration/ # 🔧 配置类
|
||||
│ │ ├── clusters/
|
||||
│ │ └── registries/
|
||||
│ ├── monitoring/ # 📊 监控类
|
||||
│ │ └── clusters/
|
||||
│ ├── artifact/ # 📦 制品类
|
||||
│ │ ├── registries/
|
||||
│ │ └── instances/
|
||||
│ ├── auth/
|
||||
│ └── home/
|
||||
│
|
||||
├── core/ # 核心层
|
||||
│ ├── api/ # API 调用
|
||||
│ ├── graphql/ # GraphQL 客户端
|
||||
│ ├── types/ # 类型定义
|
||||
│ └── config/ # 配置
|
||||
│
|
||||
└── shared/ # 共享层
|
||||
├── components/ # 通用组件
|
||||
├── hooks/ # 通用 hooks
|
||||
├── utils/ # 工具函数
|
||||
└── services/ # 服务
|
||||
|
||||
```
|
||||
|
||||
## 🎯 Features 模块分类
|
||||
|
||||
### Configuration(配置类)
|
||||
- `clusters/` - 配置 K8s 集群连接
|
||||
- `registries/` - 配置 OCI 仓库连接
|
||||
|
||||
### Monitoring(监控类)
|
||||
- `clusters/` - 监控集群状态
|
||||
|
||||
### Artifact(制品类)
|
||||
- `registries/` - 浏览制品仓库内容
|
||||
- `instances/` - 管理已部署的服务实例
|
||||
|
||||
## 🔄 API 架构
|
||||
|
||||
- **GraphQL** - 用于 CRUD 操作(集群、仓库、实例)
|
||||
- **RESTful** - 用于代理和实时查询(OCI、K8s 状态)
|
||||
- **Unified API** - 自动根据配置切换 GraphQL/RESTful
|
||||
|
||||
## 🚀 实例管理术语
|
||||
|
||||
- **Launch** - 启动/创建服务实例
|
||||
- **Modify** - 修改实例配置
|
||||
- **Terminate** - 终止实例运行
|
||||
|
||||
## 📝 说明
|
||||
|
||||
- 所有功能模块按**业务类别**分类(配置、监控、制品)
|
||||
- Features 模块名使用**单数**形式(configuration, artifact)
|
||||
- URL 路径保持 RESTful 风格(`/artifact/registries`)
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@ -36,9 +36,6 @@ build/
|
||||
backend/bin/
|
||||
frontend/dist/
|
||||
|
||||
# Compiled binaries
|
||||
backend/ocdp-backend
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
@ -63,18 +60,19 @@ redis_data/
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
.fuse_hidden*
|
||||
|
||||
# Next.js stale build caches
|
||||
frontend/.next.stale*/
|
||||
|
||||
# Debug/temp scripts
|
||||
# Debug scripts
|
||||
debug_*.py
|
||||
test_*.py
|
||||
|
||||
# Kubeconfig (contains sensitive credentials)
|
||||
*.kubeconfig
|
||||
kubeconfig
|
||||
# Next.js build output (including stale caches)
|
||||
frontend/.next*/
|
||||
frontend/next-env.d.ts
|
||||
|
||||
# AI model output / context storage
|
||||
# Compiled binary
|
||||
backend/ocdp-backend
|
||||
|
||||
# IDE / AI temp
|
||||
.claude/
|
||||
|
||||
|
||||
47
AGENTS.md
Normal file
47
AGENTS.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Project Overview
|
||||
|
||||
|
||||
# 🤖 Claude Code Agentic Workflow (Strictly Follow)
|
||||
|
||||
作为本项目的资深 AI 研发工程师,你在执行任何指令时,必须严格遵守以下核心原则与工作流。
|
||||
|
||||
## Ⅰ. 核心原则 (Core Principles)
|
||||
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
|
||||
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky,在掌握全局上下文后,用优雅的方式重构它(但不要过度设计)。
|
||||
3. **Test-Driven Quality (测试驱动质量):** 在项目根目录维护 `test/` 文件夹,存放结构化测试脚本。每个脚本顶部必须用注释注明其覆盖的功能范围。当代码发生重大变更时,必须执行 `test/` 下所有相关测试脚本并确保通过,方可视为任务完成。
|
||||
|
||||
## Ⅱ. 任务管理闭环 (Task Management Protocol)
|
||||
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:
|
||||
1. **Plan First:** 在开始实现前,将计划写入 `tasks/todo.md`,必须是可勾选的 Checkbox 列表。
|
||||
2. **Verify Plan:** 在动手写代码前,先和我(User)确认这个计划是否合理。
|
||||
3. **Track Progress:** 边做边在 `todo.md` 中打勾标记完成状态。
|
||||
4. **Explain Changes:** 在每执行完一个步骤时,给出高层次的代码修改总结。
|
||||
5. **Document Results:** 任务完成后,在 `todo.md` 中补充 Review 总结。
|
||||
6. **Capture Lessons:** 如果被我纠正了错误,立刻更新 `tasks/lessons.md`。
|
||||
|
||||
## Ⅲ. 工作流编排 (Workflow Orchestration)
|
||||
|
||||
### 1. 强制规划模式 (Plan Node Default)
|
||||
- 对于任何非琐碎任务(涉及 3 个以上步骤或架构决策),必须进入规划模式。
|
||||
- 提前写好详细的 Spec 以减少歧义。
|
||||
- **一旦情况不对劲(报错连连),立即停止盲目推进**,重新评估并制定新计划。
|
||||
|
||||
### 2. 经验自我迭代 (Self-Improvement Loop)
|
||||
- 在每次会话开始时,主动读取 `tasks/lessons.md`,复习该项目的历史教训。
|
||||
- 针对犯过的错误,为自己制定防止再次踩坑的规则。
|
||||
- 无情地迭代这些经验,直到你的错误率显著下降。
|
||||
|
||||
### 3. 自主修复 Bug (Autonomous Bug Fixing)
|
||||
- 当我给你一个 Bug 报告时:**直接去修。不要等我手把手教你。**
|
||||
- 主动利用 CLI 权限去查看日志、定位错误代码、运行失败的测试用例,然后解决它。
|
||||
- 要求对用户“零上下文切换”——你去修复 CI 测试,不需要我告诉你具体该怎么做。
|
||||
|
||||
### 4. 交付前绝对验证 (Verification Before Done)
|
||||
- **永远不要在没有证明代码能跑的情况下,把任务标记为“完成”。**
|
||||
- 问自己:“Staff Engineer(主任工程师)会批准这段代码吗?”
|
||||
- 必须主动运行测试(例如 `go test`, `npm run build`),检查日志,并向我证明正确性。
|
||||
- 对比修改前后的 Diff,确保行为符合预期。
|
||||
|
||||
### 5. 复杂问题拆解 (Agentic Strategy)
|
||||
- 遇到极其复杂的问题时,不要试图在一个终端窗口内硬扛。
|
||||
- 拆解子任务,主动进行探索性研究,针对焦点问题逐一击破。
|
||||
@ -8,6 +8,7 @@
|
||||
## Ⅰ. 核心原则 (Core Principles)
|
||||
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
|
||||
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky,在掌握全局上下文后,用优雅的方式重构它(但不要过度设计)。
|
||||
3. **Test-Driven Quality (测试驱动质量):** 在项目根目录维护 `test/` 文件夹,存放结构化测试脚本。每个脚本顶部必须用注释注明其覆盖的功能范围。当代码发生重大变更时,必须执行 `test/` 下所有相关测试脚本并确保通过,方可视为任务完成。
|
||||
|
||||
## Ⅱ. 任务管理闭环 (Task Management Protocol)
|
||||
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:
|
||||
|
||||
543
COMMANDS_CHEATSHEET.md
Normal file
543
COMMANDS_CHEATSHEET.md
Normal file
@ -0,0 +1,543 @@
|
||||
# OCDP 命令速查表
|
||||
|
||||
快速查找常用的 Docker 和 Make 命令。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
```bash
|
||||
# 开发环境(推荐,Mock 模式)
|
||||
make docker-dev
|
||||
|
||||
# 生产环境(真实数据库)
|
||||
make docker-prod
|
||||
|
||||
# 测试后端(独立)
|
||||
make docker-test-backend
|
||||
|
||||
# 测试前端(独立)
|
||||
make docker-test-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整命令列表
|
||||
|
||||
### Docker 服务管理
|
||||
|
||||
| 命令 | 说明 | 模式 |
|
||||
|------|------|------|
|
||||
| `make docker-dev` | 启动开发环境(前台) | Dev |
|
||||
| `make docker-dev-bg` | 启动开发环境(后台) | Dev |
|
||||
| `make docker-prod` | 启动生产环境 | Production |
|
||||
| `make docker-up` | 同 docker-prod | Production |
|
||||
| `make docker-down` | 停止所有服务 | - |
|
||||
| `make docker-down-v` | 停止并删除数据卷 | - |
|
||||
|
||||
### 服务测试
|
||||
|
||||
| 命令 | 说明 | 端口 |
|
||||
|------|------|------|
|
||||
| `make docker-test-backend` | 测试后端(前台) | 8080 |
|
||||
| `make docker-test-backend-bg` | 测试后端(后台) | 8080 |
|
||||
| `make docker-test-frontend` | 测试前端(前台) | 3000 |
|
||||
| `make docker-test-frontend-bg` | 测试前端(后台) | 3000 |
|
||||
|
||||
### 日志查看
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `make docker-logs` | 查看所有服务日志 |
|
||||
| `make docker-logs-backend` | 只看后端日志 |
|
||||
| `make docker-logs-frontend` | 只看前端日志 |
|
||||
| `make docker-logs-db` | 只看数据库日志 |
|
||||
|
||||
### 服务状态
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `make docker-ps` | 查看服务状态 |
|
||||
| `make docker-status` | 查看详细状态(含健康检查) |
|
||||
|
||||
### 镜像管理
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `make docker-build` | 构建所有镜像 |
|
||||
| `make docker-build-no-cache` | 无缓存构建 |
|
||||
| `make docker-restart` | 重启所有服务 |
|
||||
| `make docker-restart-backend` | 只重启后端 |
|
||||
| `make docker-restart-frontend` | 只重启前端 |
|
||||
|
||||
### 工具服务
|
||||
|
||||
| 命令 | 说明 | 端口 |
|
||||
|------|------|------|
|
||||
| `make docker-tools` | 启动 pgAdmin + Swagger UI | 5050, 8081 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 原始 Docker Compose 命令
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 启动(前台)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
# 启动(后台)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
# 停止
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||
|
||||
# 查看日志
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
|
||||
|
||||
# 重启服务
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart backend
|
||||
```
|
||||
|
||||
### 生产模式
|
||||
|
||||
```bash
|
||||
# 启动(前台)
|
||||
docker compose up
|
||||
|
||||
# 启动(后台)
|
||||
docker compose up -d
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f
|
||||
|
||||
# 重启服务
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### Mock 模式
|
||||
|
||||
```bash
|
||||
# 测试后端
|
||||
docker compose -f docker-compose.mock.yml up backend
|
||||
|
||||
# 测试前端
|
||||
docker compose -f docker-compose.mock.yml up frontend
|
||||
|
||||
# 后台运行
|
||||
docker compose -f docker-compose.mock.yml up -d backend
|
||||
|
||||
# 停止
|
||||
docker compose -f docker-compose.mock.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 本地开发命令(不使用 Docker)
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
make install # 安装所有依赖
|
||||
make install-tools # 安装 OpenAPI 工具
|
||||
make install-deps # 安装项目依赖
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
make dev-local # 启动完整环境
|
||||
make dev-backend-mock # 只启动后端(Mock)
|
||||
make dev-backend-prod # 只启动后端(生产)
|
||||
make dev-frontend # 只启动前端
|
||||
```
|
||||
|
||||
### OpenAPI 工作流
|
||||
|
||||
```bash
|
||||
make openapi-validate # 验证 OpenAPI 规范
|
||||
make openapi-gen # 生成前后端代码
|
||||
make openapi-gen-backend # 只生成后端代码
|
||||
make openapi-gen-frontend # 只生成前端代码
|
||||
make openapi-docs # 生成 HTML 文档
|
||||
make openapi-view # 启动文档服务器
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
make test # 运行所有测试
|
||||
make test-backend # 运行后端测试
|
||||
make test-frontend # 运行前端测试
|
||||
```
|
||||
|
||||
### 清理
|
||||
|
||||
```bash
|
||||
make clean # 清理构建产物
|
||||
make clean-generated # 清理生成的代码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 常用 Docker 命令
|
||||
|
||||
### 容器操作
|
||||
|
||||
```bash
|
||||
# 进入容器
|
||||
docker compose exec backend sh
|
||||
docker compose exec frontend sh
|
||||
docker compose exec postgres psql -U postgres -d ocdp
|
||||
|
||||
# 查看容器列表
|
||||
docker compose ps
|
||||
|
||||
# 查看容器详情
|
||||
docker compose ps -a
|
||||
|
||||
# 删除容器
|
||||
docker compose rm backend
|
||||
docker compose rm -f backend # 强制删除
|
||||
|
||||
# 停止特定容器
|
||||
docker compose stop backend
|
||||
docker compose stop frontend
|
||||
```
|
||||
|
||||
### 日志操作
|
||||
|
||||
```bash
|
||||
# 实时日志
|
||||
docker compose logs -f
|
||||
|
||||
# 只看特定服务
|
||||
docker compose logs -f backend
|
||||
|
||||
# 显示最后 N 行
|
||||
docker compose logs --tail=100 backend
|
||||
|
||||
# 显示时间戳
|
||||
docker compose logs -f -t backend
|
||||
|
||||
# 不跟随(只显示现有)
|
||||
docker compose logs backend
|
||||
```
|
||||
|
||||
### 镜像操作
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker compose build
|
||||
|
||||
# 无缓存构建
|
||||
docker compose build --no-cache
|
||||
|
||||
# 只构建特定服务
|
||||
docker compose build backend
|
||||
docker compose build frontend
|
||||
|
||||
# 查看镜像
|
||||
docker images | grep ocdp
|
||||
|
||||
# 删除镜像
|
||||
docker rmi ocdp-backend
|
||||
docker rmi ocdp-frontend
|
||||
```
|
||||
|
||||
### 数据卷操作
|
||||
|
||||
```bash
|
||||
# 查看数据卷
|
||||
docker volume ls
|
||||
|
||||
# 删除特定数据卷
|
||||
docker volume rm ocdp_postgres_data
|
||||
docker volume rm ocdp_redis_data
|
||||
|
||||
# 删除所有未使用的数据卷
|
||||
docker volume prune
|
||||
|
||||
# 备份数据卷
|
||||
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz -C /data .
|
||||
|
||||
# 恢复数据卷
|
||||
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine sh -c "cd /data && tar xzf /backup/postgres_backup.tar.gz"
|
||||
```
|
||||
|
||||
### 网络操作
|
||||
|
||||
```bash
|
||||
# 查看网络
|
||||
docker network ls
|
||||
|
||||
# 查看网络详情
|
||||
docker network inspect ocdp-network
|
||||
|
||||
# 测试网络连通性
|
||||
docker compose exec backend ping frontend
|
||||
docker compose exec frontend ping backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 调试命令
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
# 后端健康检查
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 前端健康检查
|
||||
curl http://localhost:5173/ # Dev 模式
|
||||
curl http://localhost:3000/ # Production 模式
|
||||
|
||||
# 数据库健康检查
|
||||
docker compose exec postgres pg_isready -U postgres
|
||||
```
|
||||
|
||||
### API 测试
|
||||
|
||||
```bash
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
|
||||
# 获取 Registries
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
|
||||
# 获取 Clusters
|
||||
curl http://localhost:8080/api/v1/clusters
|
||||
|
||||
# 获取 Repositories
|
||||
curl http://localhost:8080/api/v1/registries/harbor-prod/repositories
|
||||
|
||||
# 获取 Artifacts(带过滤)
|
||||
curl "http://localhost:8080/api/v1/registries/harbor-prod/repositories/charts%2Fvllm-serve/artifacts?media_type=chart"
|
||||
```
|
||||
|
||||
### 数据库操作
|
||||
|
||||
```bash
|
||||
# 连接数据库
|
||||
docker compose exec postgres psql -U postgres -d ocdp
|
||||
|
||||
# 查看表
|
||||
docker compose exec postgres psql -U postgres -d ocdp -c "\dt"
|
||||
|
||||
# 查询数据
|
||||
docker compose exec postgres psql -U postgres -d ocdp -c "SELECT * FROM registries;"
|
||||
|
||||
# 执行 SQL 文件
|
||||
docker compose exec -T postgres psql -U postgres -d ocdp < schema.sql
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
|
||||
```bash
|
||||
# 查看资源使用
|
||||
docker stats
|
||||
|
||||
# 只看特定容器
|
||||
docker stats ocdp-backend ocdp-frontend
|
||||
|
||||
# 查看容器进程
|
||||
docker compose top backend
|
||||
docker compose top frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 常用组合命令
|
||||
|
||||
### 完全重置
|
||||
|
||||
```bash
|
||||
# 停止并删除所有内容
|
||||
docker compose down -v
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
docker compose -f docker-compose.mock.yml down -v
|
||||
|
||||
# 删除镜像
|
||||
docker rmi $(docker images | grep ocdp | awk '{print $3}')
|
||||
|
||||
# 重新构建
|
||||
make docker-build
|
||||
|
||||
# 重新启动
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
### 更新代码后重新部署
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
make docker-down
|
||||
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建
|
||||
make docker-build
|
||||
|
||||
# 启动服务
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
### 查看完整系统状态
|
||||
|
||||
```bash
|
||||
# 查看所有容器
|
||||
docker compose ps
|
||||
|
||||
# 查看所有日志
|
||||
make docker-logs
|
||||
|
||||
# 查看健康状态
|
||||
make docker-status
|
||||
|
||||
# 测试所有服务
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:5173/
|
||||
curl http://localhost:5432 # 数据库端口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 环境变量
|
||||
|
||||
### 后端环境变量
|
||||
|
||||
```bash
|
||||
# Mock 模式
|
||||
export ADAPTER_MODE=mock
|
||||
export JWT_SECRET=dev-secret
|
||||
export ENCRYPTION_KEY=dev-encryption-key-32-bytes-long
|
||||
|
||||
# Production 模式
|
||||
export ADAPTER_MODE=production
|
||||
export DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ocdp?sslmode=disable
|
||||
export JWT_SECRET=your-production-secret
|
||||
export ENCRYPTION_KEY=your-production-encryption-key-32-bytes
|
||||
```
|
||||
|
||||
### 前端环境变量
|
||||
|
||||
```bash
|
||||
export VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
export VITE_USE_MOCK=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 故障排查命令
|
||||
|
||||
### 问题诊断
|
||||
|
||||
```bash
|
||||
# 查看完整日志
|
||||
docker compose logs --tail=1000 > debug.log
|
||||
|
||||
# 查看特定时间段日志
|
||||
docker compose logs --since 10m backend
|
||||
|
||||
# 查看容器详细信息
|
||||
docker compose exec backend env
|
||||
|
||||
# 检查配置
|
||||
docker compose config
|
||||
|
||||
# 验证 docker-compose.yml
|
||||
docker compose -f docker-compose.yml config
|
||||
docker compose -f docker-compose.dev.yml config
|
||||
```
|
||||
|
||||
### 端口冲突检查
|
||||
|
||||
```bash
|
||||
# 查看端口占用
|
||||
sudo lsof -i :8080
|
||||
sudo lsof -i :5173
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :5432
|
||||
|
||||
# 杀死进程
|
||||
sudo kill -9 <PID>
|
||||
```
|
||||
|
||||
### 磁盘空间清理
|
||||
|
||||
```bash
|
||||
# 删除未使用的容器
|
||||
docker container prune
|
||||
|
||||
# 删除未使用的镜像
|
||||
docker image prune
|
||||
|
||||
# 删除未使用的数据卷
|
||||
docker volume prune
|
||||
|
||||
# 删除所有未使用的资源
|
||||
docker system prune -a --volumes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 实用技巧
|
||||
|
||||
### 1. 后台运行并查看日志
|
||||
|
||||
```bash
|
||||
make docker-dev-bg && make docker-logs
|
||||
```
|
||||
|
||||
### 2. 重启并查看日志
|
||||
|
||||
```bash
|
||||
docker compose restart backend && docker compose logs -f backend
|
||||
```
|
||||
|
||||
### 3. 构建并启动
|
||||
|
||||
```bash
|
||||
make docker-build && make docker-dev
|
||||
```
|
||||
|
||||
### 4. 清理并重新开始
|
||||
|
||||
```bash
|
||||
make docker-down && docker system prune -f && make docker-dev
|
||||
```
|
||||
|
||||
### 5. 快速测试 API
|
||||
|
||||
```bash
|
||||
# 保存 token
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')
|
||||
|
||||
# 使用 token
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/registries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 更多资源
|
||||
|
||||
- [快速开始](./QUICK_START.md)
|
||||
- [Docker 服务架构](./DOCKER_SERVICES.md)
|
||||
- [项目重构总结](./PROJECT_RESTRUCTURE_SUMMARY.md)
|
||||
- [README](./README.md)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>命令速查表 - 最后更新:2025-11-09</sub>
|
||||
</div>
|
||||
|
||||
232
Makefile
232
Makefile
@ -1,192 +1,68 @@
|
||||
# ============================================================
|
||||
# OCDP - Open Cloud Development Platform
|
||||
# Makefile for Docker Compose deployment
|
||||
# OCDP root orchestration Makefile
|
||||
# ============================================================
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
# ============================================================
|
||||
# Configuration - Modify these for your environment
|
||||
# ============================================================
|
||||
COMPOSE_BIN ?= docker compose
|
||||
ROOT_COMPOSE := docker-compose.yml
|
||||
COMPOSE := $(COMPOSE_BIN) -f $(ROOT_COMPOSE)
|
||||
|
||||
# Server IP for external access (客户端访问IP)
|
||||
SERVER_IP ?= 10.6.80.114
|
||||
.PHONY: help install run-2 clean-2 docker-dev docker-prod docker-up docker-down docker-logs docker-ps test
|
||||
|
||||
# Backend configuration
|
||||
BACKEND_PORT ?= 8080
|
||||
JWT_SECRET ?= change-me-in-production
|
||||
ENCRYPTION_KEY ?= change-me-32-bytes-long-key-here
|
||||
DATABASE_URL ?= postgresql://postgres:postgres@postgres:5432/ocdp?sslmode=disable
|
||||
ADAPTER_MODE ?= production
|
||||
|
||||
# Allowed CORS origins (for external access)
|
||||
# 格式: http://IP:端口,多个用逗号分隔
|
||||
ALLOWED_ORIGINS ?= http://$(SERVER_IP),http://$(SERVER_IP):3000
|
||||
|
||||
# Compose files
|
||||
COMPOSE_FILES := -f docker-compose.yml -f backend/docker-compose.yml
|
||||
|
||||
# Database init SQL path (relative to project root)
|
||||
INIT_DB_SQL_PATH ?= ./backend/scripts/init-db.sql
|
||||
|
||||
# ============================================================
|
||||
# Production Commands (Docker Compose)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: up down restart clean logs logs-backend logs-frontend status help
|
||||
|
||||
# Start all services
|
||||
up:
|
||||
@echo "============================================"
|
||||
@echo "Starting OCDP services..."
|
||||
@echo "Server IP: $(SERVER_IP)"
|
||||
@echo "============================================"
|
||||
@ALLOWED_DEV_ORIGINS="$(ALLOWED_ORIGINS)" \
|
||||
DATABASE_URL="$(DATABASE_URL)" \
|
||||
JWT_SECRET="$(JWT_SECRET)" \
|
||||
ENCRYPTION_KEY="$(ENCRYPTION_KEY)" \
|
||||
ADAPTER_MODE="$(ADAPTER_MODE)" \
|
||||
BACKEND_PORT=$(BACKEND_PORT) \
|
||||
INIT_DB_SQL_PATH="$(INIT_DB_SQL_PATH)" \
|
||||
docker compose $(COMPOSE_FILES) --profile backend up -d
|
||||
@echo ""
|
||||
@echo "✅ Services started:"
|
||||
@echo " Frontend: http://$(SERVER_IP)"
|
||||
@echo " Backend: http://$(SERVER_IP):$(BACKEND_PORT)/api/v1"
|
||||
@echo " Swagger: http://$(SERVER_IP):$(BACKEND_PORT)/api/docs"
|
||||
@echo " PostgreSQL: localhost:5432"
|
||||
@echo ""
|
||||
@echo " Default login: admin / admin123"
|
||||
@echo "============================================"
|
||||
|
||||
# Stop all services (保留数据)
|
||||
down:
|
||||
@echo "Stopping OCDP services..."
|
||||
@docker compose $(COMPOSE_FILES) down
|
||||
|
||||
# Restart all services
|
||||
restart: down up
|
||||
|
||||
# Full cleanup (删除所有数据卷)
|
||||
clean:
|
||||
@echo "============================================"
|
||||
@echo "⚠️ Full cleanup - 警告:此操作将删除所有数据!"
|
||||
@echo "============================================"
|
||||
@docker compose $(COMPOSE_FILES) down -v
|
||||
@echo "✅ All services stopped and data removed"
|
||||
|
||||
# ============================================================
|
||||
# Build Commands
|
||||
# ============================================================
|
||||
|
||||
.PHONY: build build-frontend build-backend rebuild
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
@docker compose $(COMPOSE_FILES) build
|
||||
|
||||
# Rebuild and start (强制重建前端)
|
||||
rebuild:
|
||||
@docker compose $(COMPOSE_FILES) up -d --build --force-recreate
|
||||
|
||||
# Rebuild frontend only
|
||||
build-frontend:
|
||||
@docker compose -f docker-compose.yml build frontend
|
||||
|
||||
# Rebuild backend only
|
||||
build-backend:
|
||||
@docker compose -f backend/docker-compose.yml build backend
|
||||
|
||||
# ============================================================
|
||||
# Log Commands
|
||||
# ============================================================
|
||||
|
||||
# View all logs
|
||||
logs:
|
||||
@docker compose $(COMPOSE_FILES) logs -f
|
||||
|
||||
# View backend logs
|
||||
logs-backend:
|
||||
@docker logs -f ocdp-backend
|
||||
|
||||
# View frontend logs
|
||||
logs-frontend:
|
||||
@docker logs -f ocdp-frontend
|
||||
|
||||
# View nginx logs
|
||||
logs-nginx:
|
||||
@docker logs -f ocdp-nginx
|
||||
|
||||
# ============================================================
|
||||
# Status Commands
|
||||
# ============================================================
|
||||
|
||||
# Show service status
|
||||
status:
|
||||
@docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
# ============================================================
|
||||
# Database Commands
|
||||
# ============================================================
|
||||
|
||||
.PHONY: db-reset db-init db-shell
|
||||
|
||||
# Reset database (删除并重建)
|
||||
db-reset:
|
||||
@echo "Resetting database..."
|
||||
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -c "DROP DATABASE IF EXISTS ocdp;" || true
|
||||
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -c "CREATE DATABASE ocdp;" || true
|
||||
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -d ocdp -c "$$(cat backend/scripts/init-db.sql)"
|
||||
|
||||
# Initialize database
|
||||
db-init:
|
||||
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -d ocdp -c "$$(cat backend/scripts/init-db.sql)"
|
||||
|
||||
# Open database shell
|
||||
db-shell:
|
||||
@docker compose $(COMPOSE_FILES) exec postgres psql -U postgres -d ocdp
|
||||
|
||||
# ============================================================
|
||||
# Help
|
||||
# ============================================================
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
help:
|
||||
@echo "OCDP - Open Cloud Deployment Platform"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo "OCDP commands"
|
||||
@echo "────────────────────────────────────────"
|
||||
@echo " make install Install local Go / frontend dependencies"
|
||||
@echo " make run-2 Build and start full Docker Compose stack in background"
|
||||
@echo " make docker-dev Alias of run-2, kept for old docs / muscle memory"
|
||||
@echo " make docker-prod Alias of run-2"
|
||||
@echo " make docker-up Alias of run-2"
|
||||
@echo " make docker-down Stop containers, keep volumes"
|
||||
@echo " make clean-2 Stop containers and remove project volumes"
|
||||
@echo " make docker-logs Follow Compose logs"
|
||||
@echo " make docker-ps Show Compose service status"
|
||||
@echo " make test Run structured verification script"
|
||||
@echo ""
|
||||
@echo "Main Commands:"
|
||||
@echo " make up - 启动所有服务"
|
||||
@echo " make down - 停止所有服务(保留数据)"
|
||||
@echo " make restart - 重启所有服务"
|
||||
@echo " make clean - 完全清理(删除所有数据)"
|
||||
@echo " make rebuild - 强制重建并启动"
|
||||
@echo "Default local ports: web=18080, https=18443, backend=18081, postgres=15432"
|
||||
@echo "Override with WEB_HTTP_PORT / WEB_HTTPS_PORT / BACKEND_PORT / POSTGRES_PORT."
|
||||
@echo ""
|
||||
@echo "Build Commands:"
|
||||
@echo " make build - 构建所有镜像"
|
||||
@echo " make build-frontend - 只构建前端"
|
||||
@echo " make build-backend - 只构建后端"
|
||||
|
||||
install:
|
||||
@echo "→ Downloading backend modules"
|
||||
@cd backend && go mod download
|
||||
@echo "→ Installing frontend dependencies"
|
||||
@cd frontend && npm ci
|
||||
|
||||
run-2:
|
||||
@echo "→ Building and starting OCDP stack"
|
||||
@$(COMPOSE) up --build -d postgres backend nginx
|
||||
@echo ""
|
||||
@echo "Log Commands:"
|
||||
@echo " make logs - 查看所有日志"
|
||||
@echo " make logs-backend - 只看后端日志"
|
||||
@echo " make logs-frontend - 只看前端日志"
|
||||
@echo " make logs-nginx - 只看nginx日志"
|
||||
@$(COMPOSE) ps
|
||||
@echo ""
|
||||
@echo "Database Commands:"
|
||||
@echo " make db-reset - 重置数据库"
|
||||
@echo " make db-init - 初始化数据库"
|
||||
@echo " make db-shell - 进入数据库终端"
|
||||
@echo ""
|
||||
@echo "Utility Commands:"
|
||||
@echo " make status - 查看服务状态"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " SERVER_IP=$(SERVER_IP) - 服务器IP(默认: 10.6.80.114)"
|
||||
@echo " BACKEND_PORT=$(BACKEND_PORT) - 后端端口(默认: 8080)"
|
||||
@echo " ALLOWED_ORIGINS=$(ALLOWED_ORIGINS) - 允许的跨域来源"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make up SERVER_IP=192.168.1.100 # 自定义IP启动"
|
||||
@echo " make clean # 完全清理并重新开始"
|
||||
@echo "============================================"
|
||||
@echo "Web: http://localhost:$${WEB_HTTP_PORT:-18080}"
|
||||
@echo "Backend: http://localhost:$${BACKEND_PORT:-18081}/health"
|
||||
|
||||
docker-dev: run-2
|
||||
|
||||
docker-prod: run-2
|
||||
|
||||
docker-up: run-2
|
||||
|
||||
docker-down:
|
||||
@$(COMPOSE) down --remove-orphans
|
||||
|
||||
clean-2:
|
||||
@$(COMPOSE) down -v --remove-orphans
|
||||
|
||||
docker-logs:
|
||||
@$(COMPOSE) logs -f
|
||||
|
||||
docker-ps:
|
||||
@$(COMPOSE) ps
|
||||
|
||||
test:
|
||||
@test/readme-deployment-refresh.sh
|
||||
|
||||
@ -1,311 +0,0 @@
|
||||
# OCDP 端到端部署流程 - 实现计划
|
||||
|
||||
## Context
|
||||
|
||||
OCDP (One Click Deployment Platform) 是一个云原生一键部署平台,核心目标:**让用户能够通过简单的操作,从 OCI Registry(如 Harbor)拉取 Helm Charts 并一键部署到 Kubernetes 集群**。
|
||||
|
||||
当前状态:
|
||||
- 后端 Helm 部署链路已实现(使用 helm.sh/helm/v3 SDK)
|
||||
- Charts 浏览器可以拉取并显示 OCI Registry 中的 Helm Charts
|
||||
- 但部署到 K8s 时报错
|
||||
|
||||
本文档聚焦于:**完整打通 Admin 创建用户到 User 一键部署的整个流程,并验证端到端可用**。
|
||||
|
||||
---
|
||||
|
||||
## 一、当前代码库状态
|
||||
|
||||
### 1.1 后端架构(已实现)
|
||||
|
||||
| 层级 | 组件 | 状态 |
|
||||
|------|------|------|
|
||||
| **输入适配器** | REST Handlers (user, workspace, registry, instance, chart, storage, template) | ✅ 全部实现 |
|
||||
| **领域层** | Services (auth, cluster, registry, instance, storage, template, workspace) | ✅ 全部实现 |
|
||||
| **输出适配器** | PostgreSQL Repository | ✅ 实现 |
|
||||
| | OCI Client (ORAS) | ✅ 实现,从 Harbor 拉取 Charts |
|
||||
| | Helm Client (helm.sh/helm/v3) | ✅ 实现,真实调用 Helm 命令 |
|
||||
| | K8s Client | ✅ 实现,查询 Services/Ingresses |
|
||||
|
||||
**部署链路**:
|
||||
```
|
||||
InstanceService.CreateInstance()
|
||||
├── 保存 instance 到 DB (status=pending)
|
||||
├── 下载 Chart (OCI → /tmp/charts/)
|
||||
└── 异步 goroutine: executeAndSyncInstall()
|
||||
├── 生成 kubeconfig (from cluster.Credentials)
|
||||
├── helm install (Helm SDK)
|
||||
└── 每 10s 轮询状态,更新 DB
|
||||
```
|
||||
|
||||
### 1.2 前端页面(已实现)
|
||||
|
||||
| 页面 | 路由 | 状态 |
|
||||
|------|------|------|
|
||||
| 登录 | `/login` | ✅ |
|
||||
| Charts 浏览器 + Deploy Modal | `/charts` | ✅ |
|
||||
| Templates 管理 | `/templates` | ✅ |
|
||||
| Storage 管理 | `/storage` | ✅ |
|
||||
| Chart References | `/chart-references` | ✅ |
|
||||
| Clusters 管理 | `/clusters` | ✅ |
|
||||
| Registries 管理 | `/registries` | ✅ |
|
||||
| Admin Workspaces | `/admin/workspaces` | ✅ |
|
||||
| Admin Users | `/admin/users` | ❓ 需确认 |
|
||||
| Monitoring | `/monitoring` | ✅ |
|
||||
|
||||
### 1.3 数据库表(已创建)
|
||||
|
||||
- `users` - 用户账户 (role: admin/user, workspace_id)
|
||||
- `workspaces` - 工作空间
|
||||
- `clusters` - K8s 集群配置 (CA/Cert/Key)
|
||||
- `registries` - OCI Registries (Harbor)
|
||||
- `instances` - 部署实例记录
|
||||
- `storage_backends` - 存储后端配置
|
||||
- `chart_references` - Chart 引用
|
||||
- `values_templates` - Values 模板(版本化)
|
||||
|
||||
---
|
||||
|
||||
## 二、待解决问题
|
||||
|
||||
### 2.1 部署报错(核心阻塞)
|
||||
|
||||
**现象**:Charts 可以拉取,但部署到 K8s 时报错
|
||||
|
||||
**可能原因**:
|
||||
1. **没有 Cluster 记录**:数据库 `clusters` 表为空
|
||||
2. **Cluster 不可达**:K8s API Server 无法访问
|
||||
3. **Credentials 无效**:存储的 CA/Cert/Key 数据格式错误
|
||||
4. **Namespace 无权限**:Helm 尝试创建 namespace 时 RBAC 不足
|
||||
5. **Registry 认证失败**:无法拉取 Helm Chart
|
||||
|
||||
**诊断步骤**:
|
||||
```bash
|
||||
# 1. 启动服务
|
||||
./start.sh
|
||||
|
||||
# 2. 检查 Clusters 表
|
||||
docker compose exec postgres psql -U ocdp -d ocdp -c "SELECT id, name, host FROM clusters;"
|
||||
|
||||
# 3. 查看后端日志
|
||||
docker compose logs -f backend | grep -i error
|
||||
|
||||
# 4. 检查 Instances 表状态
|
||||
docker compose exec postgres psql -U ocdp -d ocdp -c "SELECT id, name, status, last_error FROM instances;"
|
||||
```
|
||||
|
||||
### 2.2 Admin 用户管理 UI
|
||||
|
||||
**问题**:`/admin/users` 页面是否存在?功能是否完整?
|
||||
|
||||
**需要验证**:
|
||||
- 是否可以列出所有用户?
|
||||
- 是否可以创建新用户(指定 workspace, role)?
|
||||
- 是否有编辑/禁用/删除用户功能?
|
||||
|
||||
### 2.3 Deploy Modal 功能不完整
|
||||
|
||||
**问题**:当前 Deploy Modal 只有手动填写 values.yaml,没有 Template/Storage 选择器
|
||||
|
||||
**需要增强**:
|
||||
- 添加 Values Template 下拉选择器
|
||||
- 选择 Template 后自动填充 values.yaml
|
||||
- 添加 Storage Backend 选择器
|
||||
- 选择 Storage 后自动 merge 到 values
|
||||
|
||||
---
|
||||
|
||||
## 三、实施计划
|
||||
|
||||
### Phase 1: 诊断与修复部署问题 (P0)
|
||||
|
||||
**目标**:确保部署链路能够正常工作
|
||||
|
||||
**步骤**:
|
||||
1. 启动服务 `./start.sh`
|
||||
2. 检查 `clusters` 表是否有有效的 K8s Cluster 记录
|
||||
3. 查看后端日志定位具体错误
|
||||
4. 根据错误类型修复:
|
||||
- 无 Cluster → Admin 添加 Cluster 或插入测试数据
|
||||
- 不可达 → 检查集群配置和网络
|
||||
- Credentials 错误 → 修复 kubeconfig 生成逻辑
|
||||
- 无权限 → 配置正确的 RBAC
|
||||
|
||||
**关键文件**:
|
||||
- `backend/internal/domain/service/instance_service.go` - 部署逻辑
|
||||
- `backend/internal/adapter/output/helm/real/helm_client.go` - Helm 调用
|
||||
- `backend/internal/domain/repository/cluster_repository.go` - Cluster 数据访问
|
||||
|
||||
### Phase 2: 完善 Admin 用户管理 UI (P0)
|
||||
|
||||
**目标**:Admin 可以完整管理用户
|
||||
|
||||
**步骤**:
|
||||
1. 检查 `frontend/src/app/admin/users/page.tsx` 是否存在
|
||||
2. 如不存在,创建用户管理页面:
|
||||
- 用户列表(调用 `adminApi.listUsers()`)
|
||||
- 创建用户表单(username, password, role, workspace_id 选择)
|
||||
- 编辑用户角色/状态
|
||||
- 重置用户密码
|
||||
- 删除用户
|
||||
|
||||
3. 更新 `frontend/src/components/sidebar.tsx` 添加导航项
|
||||
|
||||
**关键文件**:
|
||||
- `frontend/src/app/admin/users/page.tsx`
|
||||
- `frontend/src/components/sidebar.tsx`
|
||||
- `frontend/src/lib/api.ts` (adminApi)
|
||||
|
||||
### Phase 3: 增强 Deploy Modal (P1)
|
||||
|
||||
**目标**:让用户可以方便地选择 Template 和 Storage
|
||||
|
||||
**步骤**:
|
||||
1. 修改 `frontend/src/app/charts/page.tsx` 中的 Deploy Modal
|
||||
2. 添加 Values Template 选择器:
|
||||
- 加载当前 chart 关联的 templates
|
||||
- 选择后自动填充 values.yaml
|
||||
3. 添加 Storage Backend 选择器:
|
||||
- 加载可用的 storage 配置
|
||||
- 选择后自动 merge 到 values
|
||||
4. 添加 loading 状态和错误处理
|
||||
|
||||
**关键文件**:
|
||||
- `frontend/src/app/charts/page.tsx`
|
||||
- `frontend/src/lib/api.ts` (valuesTemplateApi, storageApi)
|
||||
|
||||
### Phase 4: E2E 端到端验证 (P0)
|
||||
|
||||
**目标**:验证整个流程端到端可用
|
||||
|
||||
**手动测试流程**:
|
||||
```bash
|
||||
# 1. Admin 登录 (admin/admin123)
|
||||
# 2. 添加 K8s Cluster(指向真实或测试集群)
|
||||
# 3. 添加 Harbor Registry
|
||||
# 4. 创建 Workspace "test-ws"
|
||||
# 5. 创建用户 "testuser" 分配到 test-ws
|
||||
# 6. 登出,用 testuser 登录
|
||||
# 7. 浏览 Charts(选择 Registry,查看 Repositories)
|
||||
# 8. 创建 Values Template
|
||||
# 9. 部署 Chart(选择 Template)
|
||||
# 10. 查看实例状态
|
||||
# 11. 验证 Helm Release 实际部署到 K8s
|
||||
```
|
||||
|
||||
**自动化 E2E 测试**:
|
||||
更新 `e2e_test.py` 覆盖完整流程:
|
||||
- Admin 创建用户流程
|
||||
- User 登录后部署流程
|
||||
- 验证实例创建成功
|
||||
|
||||
### Phase 5: 完善 Values Template 功能 (P2)
|
||||
|
||||
**目标**:Values Template 版本管理和回滚
|
||||
|
||||
**功能**:
|
||||
- 每次更新创建新版本
|
||||
- 查看版本历史
|
||||
- 回滚到历史版本
|
||||
|
||||
**关键文件**:
|
||||
- `backend/internal/domain/service/values_template_service.go`
|
||||
- `frontend/src/app/templates/page.tsx`
|
||||
|
||||
### Phase 6: Storage 分层配置 (P2)
|
||||
|
||||
**目标**:实现分层存储配置
|
||||
|
||||
**功能**:
|
||||
- Cluster-level 默认存储
|
||||
- Workspace-level 存储覆盖
|
||||
- User Override 最高优先级
|
||||
- 默认 merge 到 values.yaml
|
||||
|
||||
---
|
||||
|
||||
## 四、验证方式
|
||||
|
||||
### 4.1 手动验证清单
|
||||
|
||||
- [ ] Admin 登录成功
|
||||
- [ ] Admin 可以添加 K8s Cluster
|
||||
- [ ] Admin 可以添加 Harbor Registry
|
||||
- [ ] Admin 可以创建 Workspace
|
||||
- [ ] Admin 可以创建 User
|
||||
- [ ] User 登录成功
|
||||
- [ ] User 可以浏览 Charts(从 Registry)
|
||||
- [ ] User 可以创建 Values Template
|
||||
- [ ] User 可以部署 Chart(选择 Template)
|
||||
- [ ] User 可以查看实例状态
|
||||
- [ ] Helm Release 实际部署到 K8s
|
||||
- [ ] User 可以查看 K8s 中的 Pods/Services
|
||||
|
||||
### 4.2 日志检查
|
||||
|
||||
部署成功后,检查:
|
||||
```bash
|
||||
# 后端日志
|
||||
docker compose logs -f backend | grep -i "install\|deploy\|helm"
|
||||
|
||||
# K8s 中的 Helm Releases
|
||||
kubectl get releases -A # 或 helm list -A
|
||||
|
||||
# 实例状态
|
||||
curl http://localhost:8080/api/v1/instances | jq
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、技术决策
|
||||
|
||||
### 5.1 Helm Client 模式
|
||||
|
||||
**生产环境**:使用真实 Helm Client (`ADAPTER_MODE=real`)
|
||||
**测试环境**:使用 Mock Client (`ADAPTER_MODE=mock`)
|
||||
|
||||
### 5.2 Cluster Credentials
|
||||
|
||||
存储方式:直接在 `clusters` 表存储 CA/Cert/Key
|
||||
生成 kubeconfig:在运行时拼接 kubeconfig YAML 文件
|
||||
|
||||
### 5.3 Namespace 隔离
|
||||
|
||||
当前实现:用户指定 namespace
|
||||
规划:Workspace 自动分配 namespace 前缀(如 `ws-{workspace}-{instance}`)
|
||||
|
||||
---
|
||||
|
||||
## 六、关键文件索引
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `backend/cmd/api/main.go` | 入口,路由注册,依赖注入 |
|
||||
| `backend/internal/adapter/input/http/rest/instance_handler.go` | 实例部署 API |
|
||||
| `backend/internal/adapter/input/http/rest/user_management_handler.go` | 用户管理 API |
|
||||
| `backend/internal/domain/service/instance_service.go` | 实例部署逻辑 |
|
||||
| `backend/internal/adapter/output/helm/real/helm_client.go` | 真实 Helm Client |
|
||||
| `backend/internal/adapter/output/oci/real/oci_client.go` | OCI Registry 客户端 |
|
||||
| `backend/internal/adapter/output/persistence/postgres/instance_repository.go` | 实例数据访问 |
|
||||
| `backend/scripts/init-db.sql` | 数据库初始化 |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `frontend/src/app/charts/page.tsx` | Charts 浏览器 + Deploy Modal |
|
||||
| `frontend/src/app/admin/users/page.tsx` | Admin 用户管理 |
|
||||
| `frontend/src/app/admin/workspaces/page.tsx` | Admin Workspace 管理 |
|
||||
| `frontend/src/app/templates/page.tsx` | Values Template 管理 |
|
||||
| `frontend/src/app/storage/page.tsx` | Storage Backend 管理 |
|
||||
| `frontend/src/app/login/page.tsx` | 登录页面 |
|
||||
| `frontend/src/lib/api.ts` | API 客户端 |
|
||||
| `frontend/src/components/sidebar.tsx` | 侧边栏导航 |
|
||||
|
||||
### 测试
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `e2e_test.py` | Playwright E2E 测试 |
|
||||
| `debug_login.py` | 登录调试脚本 |
|
||||
@ -1,669 +0,0 @@
|
||||
# OCDP 项目总结文档
|
||||
|
||||
> 创建时间: 2026-04-16
|
||||
> 最后更新: 2026-04-16
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 项目定位
|
||||
|
||||
**OCDP (One Click Deployment Platform)** — 开源云原生一键部署平台
|
||||
|
||||
**核心理念**: 让用户能够通过简单的操作,从 OCI Registry(如 Harbor)拉取 Helm Charts 并一键部署到指定 Kubernetes 集群,同时支持多租户隔离和配置模板化管理。
|
||||
|
||||
### 1.2 解决的问题
|
||||
|
||||
1. **部署复杂性**: 传统 K8s 部署需要编写复杂的 YAML、手动管理 Helm Values
|
||||
2. **配置一致性**: 不同环境(dev/staging/prod)需要不同的配置,但配置复用困难
|
||||
3. **多集群管理**: 维护多个 K8s 集群的部署一致性和状态同步
|
||||
4. **多租户隔离**: 不同团队/项目需要资源隔离和权限控制
|
||||
5. **配置版本管理**: 配置变更需要可追溯、可回滚
|
||||
|
||||
### 1.3 目标用户
|
||||
|
||||
- **DevOps 工程师**: 需要快速部署应用到多个集群
|
||||
- **开发团队**: 需要自助式部署,无需深入了解 K8s
|
||||
- **平台团队**: 需要管理多租户、quotas、和资源隔离
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 | Go 1.21+ | Hexagonal Architecture |
|
||||
| 前端 | React 18, TypeScript, Next.js | App Router |
|
||||
| 数据库 | PostgreSQL 15+ | 多租户数据存储 |
|
||||
| 网关 | Nginx | 反向代理、SSL 终止 |
|
||||
| 容器 | Docker Compose | 本地开发/部署 |
|
||||
|
||||
### 2.2 架构设计 — Hexagonal Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ http://localhost │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Nginx Reverse Proxy │
|
||||
│ http://localhost:200 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend API (Go) │
|
||||
│ http://localhost:8080 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Input Adapters (Ports) │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ REST │ │ Auth │ │ Swagger │ │ CORS │ │ │
|
||||
│ │ │ Handler │ │ Handler │ │ Handler│ │ Middleware │ │ │
|
||||
│ │ └────┬────┘ └────┬────┘ └─────────┘ └─────────────────┘ │ │
|
||||
│ └───────┼────────────┼────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───────▼────────────▼────────────────────────────────────────┐ │
|
||||
│ │ Domain Layer │ │
|
||||
│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ Services │ │ Entities │ │ │
|
||||
│ │ │ - AuthService │ │ - User, Workspace, Cluster │ │ │
|
||||
│ │ │ - ClusterService │ │ - Registry, Storage │ │ │
|
||||
│ │ │ - InstanceService│ │ - ValuesTemplate, Instance │ │ │
|
||||
│ │ │ - ... │ └─────────────────────────────┘ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌────────▼─────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Repository Interfaces │ │ │
|
||||
│ │ │ (Ports - 定义数据访问契约) │ │ │
|
||||
│ │ └────────────────────────┬────────────────────────────┘ │ │
|
||||
│ └───────────────────────────┼────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────▼────────────────────────────────┐ │
|
||||
│ │ Output Adapters │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
|
||||
│ │ │ PostgreSQL │ │ OCI │ │ Kubernetes │ │ │
|
||||
│ │ │ Repository │ │ Client │ │ (Helm, Metrics) │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 项目结构
|
||||
|
||||
```
|
||||
ocdp-go/
|
||||
├── backend/ # Go 后端 (Hexagonal Architecture)
|
||||
│ ├── cmd/api/ # 入口点
|
||||
│ │ └── main.go # 主程序,路由配置,依赖注入
|
||||
│ ├── internal/
|
||||
│ │ ├── adapter/ # 适配器层
|
||||
│ │ │ ├── input/http/ # 输入适配器(HTTP 接口)
|
||||
│ │ │ │ ├── rest/ # REST Handlers
|
||||
│ │ │ │ ├── dto/ # Data Transfer Objects
|
||||
│ │ │ │ └── middleware/ # 中间件(认证、授权)
|
||||
│ │ │ └── output/ # 输出适配器(外部服务)
|
||||
│ │ │ ├── persistence/ # 数据库实现
|
||||
│ │ │ ├── oci/ # OCI Registry 客户端
|
||||
│ │ │ ├── helm/ # Helm 客户端
|
||||
│ │ │ └── k8s/ # K8s 客户端
|
||||
│ │ ├── domain/ # 领域层(核心业务逻辑)
|
||||
│ │ │ ├── entity/ # 实体定义
|
||||
│ │ │ ├── service/ # 领域服务
|
||||
│ │ │ └── repository/ # 仓储接口
|
||||
│ │ └── pkg/ # 公共工具包
|
||||
│ └── scripts/ # 数据库脚本
|
||||
│ ├── init-db.sql # 初始数据库结构
|
||||
│ └── migrations/ # 增量迁移脚本
|
||||
├── frontend/ # Next.js 前端
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # App Router 页面
|
||||
│ │ │ ├── login/ # 登录页面
|
||||
│ │ │ ├── clusters/ # 集群管理
|
||||
│ │ │ ├── registries/ # Registry 管理
|
||||
│ │ │ ├── charts/ # Charts 浏览器(从 Harbor 直接拉取)
|
||||
│ │ │ ├── templates/ # Values 模板管理
|
||||
│ │ │ ├── storage/ # Storage Backends 管理
|
||||
│ │ │ ├── monitoring/ # 监控页面
|
||||
│ │ │ └── admin/ # 管理后台
|
||||
│ │ ├── lib/ # 工具库
|
||||
│ │ │ ├── api.ts # API 客户端
|
||||
│ │ │ ├── types.ts # TypeScript 类型
|
||||
│ │ │ └── auth-context.tsx # 认证上下文
|
||||
│ │ └── components/ # 共享组件
|
||||
│ └── public/ # 静态资源
|
||||
└── infra/nginx/ # Nginx 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、核心功能模块
|
||||
|
||||
### 3.1 认证与多租户
|
||||
|
||||
**用户体系**:
|
||||
- **Admin**: 管理员,可管理所有资源、用户、Workspace
|
||||
- **User**: 普通用户,仅能访问自己 Workspace 的资源
|
||||
- 通过 Bootstrap Process 初始化用户账号
|
||||
|
||||
**Workspace 隔离 (Rancher Project 风格)**:
|
||||
```
|
||||
K8s Cluster: k8s-prod
|
||||
│
|
||||
├── Workspace: team-alpha (一个用户属于一个 Workspace)
|
||||
│ ├── Namespaces: [app-a, app-b] (1+ 个 Namespace)
|
||||
│ ├── User: alice
|
||||
│ │ └── KubeConfig: ocdp-team-alpha-alice
|
||||
│ │ └── 权限: 限制在 namespace: team-alpha-*
|
||||
│ ├── ResourceQuota: CPU 10核, GPU 2卡
|
||||
│ └── Instances: nginx-prod, redis-prod
|
||||
│
|
||||
├── Workspace: team-beta
|
||||
│ ├── Namespaces: [app-x]
|
||||
│ ├── User: bob
|
||||
│ │ └── KubeConfig: ocdp-team-beta-bob
|
||||
│ │ └── 权限: 限制在 namespace: team-beta-*
|
||||
│ └── ResourceQuota: CPU 5核, GPU 1卡
|
||||
```
|
||||
|
||||
**Rancher Project 隔离模型说明**:
|
||||
- **1 用户 = 1 Workspace**: 用户只能属于一个 Workspace
|
||||
- **Workspace 包含多 Namespace**: 一个 Workspace 可创建多个 Namespace
|
||||
- **KubeConfig 权限隔离**: Admin 创建用户时自动生成 KubeConfig,限制在 `workspace-*` 范围内
|
||||
- **资源配额限制**: 每个 Workspace 有独立的 CPU/GPU 配额
|
||||
|
||||
**KubeConfig 自动生成流程**:
|
||||
```
|
||||
Admin 创建用户 "alice" in Workspace "team-alpha"
|
||||
↓
|
||||
Backend: 在 K8s 中创建 ServiceAccount
|
||||
└── name: ocdp-team-alpha-alice
|
||||
└── namespace: ocdp-system
|
||||
↓
|
||||
Backend: 创建 Role (限制 namespace 范围)
|
||||
└── rules: [get,list,watch,create,update,delete] on pod, deployment, service, etc.
|
||||
└── resourceNames: [team-alpha-*] (限制只能操作 workspace 的资源)
|
||||
↓
|
||||
Backend: 创建 RoleBinding
|
||||
└── subjects: [ServiceAccount: ocdp-team-alpha-alice]
|
||||
└── roleRef: Role [ocdp-team-alpha-alice]
|
||||
↓
|
||||
Backend: 生成 KubeConfig
|
||||
└── clusters: [k8s-prod]
|
||||
└── users: [ocdp-team-alpha-alice]
|
||||
└── contexts: [ocdp-team-alpha-alice@k8s-prod]
|
||||
↓
|
||||
Backend: 将 KubeConfig 注入用户账户
|
||||
└── 存储在 users.kubeconfig 字段
|
||||
└── 或通过 API 返回给用户下载
|
||||
↓
|
||||
Alice 登录后:
|
||||
└── 可下载自己的 KubeConfig
|
||||
└── 可使用 kubectl 操作: team-alpha-* 命名空间
|
||||
└── 无法访问其他 Workspace 的资源
|
||||
```
|
||||
|
||||
### 3.2 集群管理
|
||||
|
||||
**功能**:
|
||||
- 添加/编辑/删除 Kubernetes 集群
|
||||
- 支持多种认证方式: kubeconfig, CA + Cert + Key, Token
|
||||
- 健康检查和状态监控
|
||||
- 支持两种隔离模式:
|
||||
- `namespace`: 共享集群,不同 Workspace 使用不同 Namespace(**推荐**)
|
||||
- `cluster`: 独立集群模式,每个 Workspace 有独立集群凭证
|
||||
|
||||
### 3.3 Registry 管理
|
||||
|
||||
**功能**:
|
||||
- 添加 OCI Registry (Harbor, Docker Hub 等)
|
||||
- 健康检查和连接验证
|
||||
- 支持认证(用户名/密码)
|
||||
- 支持 HTTPS/HTTP (insecure 模式)
|
||||
|
||||
**使用方式**: 用户在 Charts 页面直接浏览 Registry 中的 Repositories 和 Chart 版本
|
||||
|
||||
### 3.4 Storage Backends(分层存储配置)
|
||||
|
||||
**背景**: 有状态应用(数据库、消息队列)需要持久化存储。不同集群、不同用户可能使用不同的存储后端。
|
||||
|
||||
**分层配置架构**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Layer 1: Cluster Level (最低优先级) │
|
||||
│ Admin 为集群配置默认存储(如集群共享 NFS) │
|
||||
│ 例: { server: "10.6.80.11", path: "/shared/data" } │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ (覆盖)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Layer 2: Workspace Level (中等优先级) │
|
||||
│ Workspace 管理员配置的存储路径覆盖 │
|
||||
│ 例: { server: "10.6.80.12", path: "/workspace/data" } │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ (覆盖)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3: User Override (最高优先级) │
|
||||
│ 用户在部署时可选择/覆盖存储配置 │
|
||||
│ 例: { server: "10.6.80.13", path: "/my-app/data" } │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**存储类型支持**:
|
||||
| 类型 | 配置参数 |
|
||||
|------|----------|
|
||||
| NFS | server, path |
|
||||
| PV | storageClassName, capacity, accessModes |
|
||||
| HostPath | path |
|
||||
|
||||
**使用方式**:
|
||||
- **默认 merge**: Storage 配置默认会 merge 到 values.yaml
|
||||
- **用户可覆盖**: 用户可以在 Workspace 范围内选择可用的存储后端,或手动覆盖路径
|
||||
- **非强制**: Admin 配置的 Cluster-level storage 仅作为默认值,不强制使用
|
||||
|
||||
**使用场景**:
|
||||
```yaml
|
||||
# 1. Admin 为集群配置默认存储
|
||||
Cluster: k8s-prod
|
||||
└── StorageBackend: "cluster-nfs" (server: "10.6.80.11", path: "/shared")
|
||||
|
||||
# 2. Workspace 管理员覆盖为自己的存储
|
||||
Workspace: team-alpha
|
||||
└── StorageBackend: "team-alpha-nfs" (server: "10.6.80.12", path: "/team-alpha")
|
||||
|
||||
# 3. 用户 Alice 在部署时选择 Workspace 的存储(或再次覆盖)
|
||||
Deployment Form:
|
||||
├── Storage Reference: "team-alpha-nfs" ✓
|
||||
└── Path Override: "/alice-app" (可选)
|
||||
|
||||
# 4. 最终 values.yaml (默认 merge 结果)
|
||||
persistence:
|
||||
enabled: true
|
||||
storage:
|
||||
nfs:
|
||||
server: "10.6.80.12" # 来自 Workspace 覆盖
|
||||
path: "/team-alpha/alice-app" # 用户选择的路径
|
||||
```
|
||||
|
||||
### 3.5 Values Templates(版本化配置模板)
|
||||
|
||||
**背景**: Helm 部署需要填写 Values YAML,不同环境需要不同配置,且配置需要可追溯、可回滚。
|
||||
|
||||
**功能**:
|
||||
- 创建可复用的配置模板
|
||||
- 版本化管理(每次更新创建新版本)
|
||||
- 保留历史版本,支持回滚
|
||||
- 部署时选择模板,自动填充 Values
|
||||
|
||||
**实体设计**:
|
||||
```go
|
||||
type ValuesTemplate struct {
|
||||
ID string // 唯一标识
|
||||
WorkspaceID string // 属于哪个 Workspace
|
||||
OwnerID string // 创建者
|
||||
Name string // 模板名称 (e.g., "production", "development")
|
||||
Description string // 描述
|
||||
ValuesYAML string // Helm Values YAML 内容
|
||||
Version int // 版本号(递增)
|
||||
IsDefault bool // 是否为默认模板
|
||||
}
|
||||
```
|
||||
|
||||
**版本管理机制**:
|
||||
- 每次更新模板都会创建新版本 (Version++)
|
||||
- 保留历史版本,可查看变更记录
|
||||
- 支持回滚到任意历史版本
|
||||
|
||||
### 3.6 一键部署流程
|
||||
|
||||
**完整工作流**:
|
||||
```
|
||||
┌──────────────┐ ┌───────────────────┐ ┌─────────────────┐
|
||||
│ User │ │ OCDP Backend │ │ Target K8s │
|
||||
│ (Web UI) │────▶│ │────▶│ Cluster │
|
||||
└──────────────┘ └───────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ Registry │ │ Template │ │ Helm │
|
||||
│ (Harbor) │ │ + Global │ │ Client │
|
||||
│ │ │ Config │ │ │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
**部署步骤**:
|
||||
1. **选择 Registry**: 从已配置的 Harbor 中选择
|
||||
2. **浏览 Charts**: 直接浏览 Repositories 和 Chart 版本
|
||||
3. **选择 Values Template**: 或使用空白 Values(官方模板)
|
||||
4. **可选: 添加 User Override**: 覆盖模板中的部分配置
|
||||
5. **可选: 选择 Storage Backend**: 使用 Cluster/Workspace 默认或手动选择/覆盖
|
||||
6. **选择 Target Cluster 和 Namespace**: 系统自动分配 Workspace 隔离的 Namespace
|
||||
7. **点击 Deploy**
|
||||
8. **Backend 合并配置**: Template + User Override + Storage Backend(默认 merge,可覆盖)
|
||||
9. **Backend 调用 Helm**: 从 Registry 拉取 Chart 并安装到目标集群
|
||||
10. **Backend 同步状态**: 显示部署结果和 Helm Release 状态
|
||||
|
||||
### 3.7 资源配额与审计
|
||||
|
||||
**配额系统**:
|
||||
- Workspace 可设置 CPU、GPU、GPU Memory 的软/硬限制
|
||||
- Workspace 创建时自动在集群中创建 ResourceQuota/LimitRange
|
||||
- 部署时检查配额,超限拒绝
|
||||
- 实例删除时释放配额
|
||||
|
||||
**Namespace 隔离**:
|
||||
- 共享集群模式下,每个 Workspace 有独立的 Namespace 前缀
|
||||
- 用户只能看到和操作自己 Workspace 的 Namespace
|
||||
- 不同 Workspace 之间资源完全隔离
|
||||
|
||||
**审计日志**:
|
||||
- 记录所有创建、更新、删除、部署操作
|
||||
- 包含操作者、时间、IP、详情
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库设计
|
||||
|
||||
### 4.1 ER 图
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ workspaces │ │ users │ │ clusters │
|
||||
├─────────────┤ ├─────────────┤ ├─────────────┤
|
||||
│ id (PK) │◀──┐ │ id (PK) │ │ id (PK) │
|
||||
│ name │ │ │ username │ │ name │
|
||||
│ description │ │ │ password │ │ host │
|
||||
│ created_by │───┘ │ role │ │ workspace_id│◀─┤
|
||||
│ created_at │ │ workspace_id│──┐ │ is_shared │
|
||||
└─────────────┘ │ is_active │ │ │ isolation │
|
||||
└─────────────┘ │ └─────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
▼ │ ▼
|
||||
┌─────────────┐ │ ┌─────────────┐
|
||||
│user_config_ │ │ │ registries │
|
||||
│overrides │ │ ├─────────────┤
|
||||
├─────────────┤ │ │ id (PK) │
|
||||
│ id (PK) │ │ │ name │
|
||||
│ workspace_id│──┘ │ url │
|
||||
│ user_id │ │ workspace_id│◀─┤
|
||||
│ target_type │ │ is_shared │
|
||||
│ target_id │ └─────────────┘
|
||||
└─────────────┘ │
|
||||
│
|
||||
┌───────────────────┤
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ storage_ │ │ instances │
|
||||
│ backends │ ├─────────────┤
|
||||
├─────────────┤ │ id (PK) │
|
||||
│ id (PK) │ │ workspace_id│
|
||||
│ name │ │ cluster_id │
|
||||
│ type │ │ registry_id │
|
||||
│ workspace_id│◀─┘ │ name │
|
||||
│ config │ │ namespace │
|
||||
└─────────────┘ │ status │
|
||||
│ values_yaml │
|
||||
┌────────────│ user_override│
|
||||
▼ └─────────────┘
|
||||
┌─────────────┐
|
||||
│values_ │
|
||||
│templates │
|
||||
├─────────────┤
|
||||
│ id (PK) │
|
||||
│ name │
|
||||
│ values_yaml │
|
||||
│ version │
|
||||
│ is_default │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 4.2 核心表说明
|
||||
|
||||
| 表名 | 用途 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `users` | 用户账户 | role (admin/user), workspace_id |
|
||||
| `workspaces` | 工作空间 | name, description |
|
||||
| `workspace_quotas` | 资源配额 | workspace_id, resource_type, hard_limit, soft_limit, used |
|
||||
| `clusters` | K8s 集群 | host, kubeconfig, workspace_id, isolation_mode |
|
||||
| `registries` | OCI 仓库 | url, credentials, workspace_id |
|
||||
| `storage_backends` | Storage 全局配置 | name, type (nfs/pv/hostPath), config (JSON) |
|
||||
| `values_templates` | 值模板 | name, values_yaml, version, is_default |
|
||||
| `instances` | 部署实例 | cluster_id, release name, namespace, status, values |
|
||||
| `audit_logs` | 审计日志 | user_id, action, resource_type, details |
|
||||
|
||||
---
|
||||
|
||||
## 五、设计理念
|
||||
|
||||
### 5.1 为什么需要 Storage Backends 分层配置?
|
||||
|
||||
**问题**:
|
||||
- 一个 Admin 可能管理多个集群,每个集群有不同的存储配置
|
||||
- 同一集群中,不同用户/workspace 可能需要使用不同的存储路径
|
||||
- 存储配置需要集中管理,但也要允许灵活覆盖
|
||||
|
||||
**解决方案**:
|
||||
- **分层配置**: Cluster → Workspace → User Override,优先级递增
|
||||
- **默认 merge**: Storage 配置默认会 merge 到 values.yaml,用户可选择覆盖
|
||||
- **集中管理**: Admin 在 Cluster 层面配置默认值,Workspace 和 User 逐层覆盖
|
||||
- **灵活选择**: 用户可以在可用列表中选择存储后端,或手动调整路径
|
||||
|
||||
### 5.2 为什么需要 Values Templates?
|
||||
|
||||
**问题**:
|
||||
- Helm 部署需要填写复杂的 Values YAML
|
||||
- 不同环境(dev/staging/prod)需要不同配置
|
||||
- 配置变更后无法追溯历史
|
||||
- 相同的配置需要反复填写
|
||||
|
||||
**解决方案**:
|
||||
- 创建模板,定义标准配置
|
||||
- 支持多版本(像 Git 一样)
|
||||
- 部署时选择模板 + 可选 User Override
|
||||
- 支持回滚到历史版本
|
||||
|
||||
### 5.3 为什么需要 Workspace + Namespace 隔离?
|
||||
|
||||
**问题**:
|
||||
- 多个团队共享 K8s 集群
|
||||
- 需要资源隔离,防止相互影响
|
||||
- 需要资源配额,防止资源抢占
|
||||
|
||||
**解决方案**:
|
||||
- Workspace 对应一个或多个 Namespace
|
||||
- 共享集群模式下:`{workspace-id}-{instance-name}`
|
||||
- 自动创建 ResourceQuota/LimitRange
|
||||
- 用户只能看到自己 Workspace 的资源
|
||||
|
||||
---
|
||||
|
||||
## 六、当前状态与待完成功能
|
||||
|
||||
### 6.1 已完成 ✅
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 用户认证 | ✅ | JWT + 首次登录改密 |
|
||||
| Workspace 多租户 | ✅ | Admin/User 角色隔离 |
|
||||
| 集群管理 | ✅ | CRUD + 健康检查 |
|
||||
| Registry 管理 | ✅ | CRUD + Harbor 连接验证 |
|
||||
| Charts 浏览器 | ✅ | 从 Harbor 直接浏览 Repositories 和 Chart 版本 |
|
||||
| 实例部署 | ✅ | 创建/升级/回滚/删除(后端状态管理) |
|
||||
| 状态同步 | ✅ | 从 K8s 同步 Helm Release 状态 |
|
||||
| 监控页面 | ✅ | 集群状态、Node 指标 |
|
||||
| 配额系统 | ✅ | 数据库结构已创建 |
|
||||
| 审计日志 | ✅ | 数据库结构已创建 |
|
||||
| Storage Backends | ✅ | CRUD 管理 + 分层配置(cluster/workspace/shared 优先级解析) |
|
||||
| Values Templates | ✅ | CRUD + 版本历史 + 回滚(行式版本化,每次更新 INSERT 新行) |
|
||||
|
||||
### 6.2 待完成功能 ⏳
|
||||
|
||||
#### 高优先级 (P0)
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| **KubeConfig 自动生成** | Admin 创建用户时自动创建 SA + Role + RoleBinding 并注入 KubeConfig |
|
||||
| **部署表单集成 Values Template** | 在 Deploy Modal 中选择预定义的 Values Template |
|
||||
| **Storage Backends 分层配置** | Cluster → Workspace → User Override 分层配置 + 默认 merge |
|
||||
| **Namespace 隔离** | 每个 Workspace 有独立的 Namespace 前缀,用户不可见其他 Workspace 的资源 |
|
||||
| **K8s ResourceQuota 联动** | Workspace 创建时自动在集群中创建 ResourceQuota/LimitRange |
|
||||
|
||||
#### 中优先级 (P1)
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| **配额强制执行** | 部署时检查 CPU/GPU 配额,超限拒绝并提示 |
|
||||
| **配额使用情况 UI** | 在 Workspace 详情页显示 CPU/GPU/Memory 使用率和限制 |
|
||||
| **User Config Override** | 部署时可额外覆盖 Template 中的部分配置 |
|
||||
|
||||
### P2 已完成
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| **Values Templates 版本管理** | 创建/历史/回滚,已 E2E 验证通过 |
|
||||
| **Storage 分层配置解析** | ResolveStorageConfig 优先级 (cluster > workspace > shared),已集成到 InstanceService |
|
||||
| **Helm 实际安装到 K8s** | 验证 Helm Client 实际工作,不仅仅是状态管理 |
|
||||
| **Namespace 隔离** | 每个 Workspace 有独立的 Namespace 前缀,用户不可见其他 Workspace 的资源 |
|
||||
|
||||
#### 低优先级 (P2)
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| 完整的 E2E 测试 | 覆盖所有核心流程的自动化测试 |
|
||||
| 审计日志查看页面 | Admin 查看所有操作记录 |
|
||||
|
||||
### 实施路线图 — 当前进度 (2026-04-17)
|
||||
|
||||
```
|
||||
Phase 1: 核心部署体验 (已完成)
|
||||
├── [x] 部署表单添加 Values Template 选择器
|
||||
├── [x] Storage Backend 配置自动 merge 到 values.yaml
|
||||
└── [x] Namespace 隔离实现(Workspace → ns prefix)
|
||||
|
||||
Phase 2: 配额系统 (部分完成)
|
||||
├── [ ] 部署时配额检查
|
||||
├── [ ] K8s ResourceQuota 自动创建
|
||||
└── [ ] 配额使用 UI 展示
|
||||
|
||||
Phase 3: 完善功能 (部分完成)
|
||||
├── [ ] User Config Override
|
||||
└── [ ] 审计日志页面
|
||||
```
|
||||
|
||||
### 6.3 实施路线图
|
||||
|
||||
```
|
||||
Phase 1: 核心部署体验 (1-2 周)
|
||||
├── [ ] 部署表单添加 Values Template 选择器
|
||||
├── [ ] Storage Backend 配置自动 merge 到 values.yaml
|
||||
└── [ ] Namespace 隔离实现(Workspace → ns prefix)
|
||||
|
||||
Phase 2: 配额系统 (1 周)
|
||||
├── [ ] 部署时配额检查
|
||||
├── [ ] K8s ResourceQuota 自动创建
|
||||
└── [ ] 配额使用 UI 展示
|
||||
|
||||
Phase 3: 完善功能 (1 周)
|
||||
├── [ ] User Config Override
|
||||
└── [ ] 审计日志页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、完整的部署工作流(示例)
|
||||
|
||||
```
|
||||
1. Admin 初始化平台
|
||||
├── 添加 Harbor Registry: "harbor.company.com"
|
||||
├── 配置 Kubernetes Cluster: "k8s-prod"
|
||||
└── 配置 Cluster-level Storage Backend: NFS (10.6.80.11:/shared)
|
||||
|
||||
2. Admin 创建 Workspace "team-alpha"
|
||||
└── 设置资源配额: CPU 10核, GPU 2卡
|
||||
└── 系统自动在集群创建 ns: team-alpha-*
|
||||
└── 系统自动创建 ResourceQuota: team-alpha-quota
|
||||
└── 配置 Workspace-level Storage: NFS (10.6.80.12:/team-alpha)
|
||||
|
||||
3. Admin 创建用户 "alice",分配到 team-alpha
|
||||
└── 系统自动创建 KubeConfig (SA + Role + RoleBinding)
|
||||
└── 权限范围: namespace team-alpha-*
|
||||
└── 发送初始密码 + KubeConfig 下载链接
|
||||
|
||||
4. Alice 登录(首次需改密)
|
||||
├── 下载 KubeConfig
|
||||
│ └── 使用 kubectl 操作: team-alpha-* 命名空间
|
||||
├── 创建 Values Template: "production" (v1)
|
||||
│ └── 定义 replicaCount=3, 引用 storage 配置
|
||||
├── 部署 Instance: "my-nginx"
|
||||
│ ├── 选择 Chart: harbor.company.com/charts/nginx:1.0.0
|
||||
│ ├── 选择 Template: production
|
||||
│ ├── 选择 Storage: team-alpha-nfs (或覆盖路径)
|
||||
│ ├── 选择 Cluster: k8s-prod
|
||||
│ └── Namespace: team-alpha-my-nginx (自动分配)
|
||||
└── 查看 Instance 状态: deployed ✓
|
||||
|
||||
5. Alice 查看集群中实际资源
|
||||
├── Namespace: team-alpha-my-nginx
|
||||
├── Pods: nginx-xxx
|
||||
└── ResourceQuota: team-alpha-quota (已生效)
|
||||
|
||||
6. Alice 更新 Template (v2)
|
||||
└── replicaCount 改为 5
|
||||
|
||||
7. Alice 升级 Instance
|
||||
└── Helm upgrade my-nginx → 新配置自动应用
|
||||
|
||||
8. Admin 查看审计日志
|
||||
└── 了解所有操作记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、快速开始
|
||||
|
||||
### 8.1 启动服务
|
||||
|
||||
```bash
|
||||
# 使用一键脚本
|
||||
./start.sh
|
||||
|
||||
# 或手动启动
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### 8.2 访问
|
||||
|
||||
| 服务 | 地址 | 默认账号 |
|
||||
|------|------|----------|
|
||||
| 前端 | http://localhost | admin / admin123 |
|
||||
| API | http://localhost:8080/api/v1 | - |
|
||||
| Swagger | http://localhost:8080/api/docs | - |
|
||||
|
||||
### 8.3 创建第一个部署
|
||||
|
||||
1. **登录**: 使用 admin/admin123
|
||||
2. **添加 Registry**: Harbor 地址 + 凭证
|
||||
3. **添加 Cluster**: Kubeconfig
|
||||
4. **配置 Storage Backend**: NFS 服务器地址
|
||||
5. **创建 Workspace**: 设置配额
|
||||
6. **创建 Values Template**: 填写 Values YAML
|
||||
7. **部署**: Charts 页面 → 选择 Registry → 浏览 Chart → Deploy → 选择 Template
|
||||
8. **查看状态**: Monitoring 页面
|
||||
|
||||
---
|
||||
|
||||
## 九、参考文档
|
||||
|
||||
- [后端 README](backend/README.md)
|
||||
- [前端 README](frontend/README.md)
|
||||
- [数据库 Schema](backend/scripts/init-db.sql)
|
||||
- [API 文档](http://localhost:8080/api/docs)
|
||||
|
||||
---
|
||||
|
||||
*本文档由 Claude Code 生成,2026-04-16*
|
||||
413
QUICK_START.md
Normal file
413
QUICK_START.md
Normal file
@ -0,0 +1,413 @@
|
||||
# OCDP 快速开始指南
|
||||
|
||||
## 🚀 5分钟快速体验
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- (可选) Make 工具
|
||||
|
||||
### 第一步:克隆项目
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd ocdp-go
|
||||
```
|
||||
|
||||
### 第二步:选择运行模式
|
||||
|
||||
#### 方式 1: 开发模式(推荐用于日常开发)
|
||||
|
||||
**特点**:
|
||||
- ✅ 后端 Mock 模式(无需数据库)
|
||||
- ✅ 热重载(代码修改自动生效)
|
||||
- ✅ 快速启动
|
||||
- ✅ 适合快速迭代开发
|
||||
|
||||
```bash
|
||||
# 使用 Make
|
||||
make docker-dev
|
||||
|
||||
# 或直接使用 Docker Compose
|
||||
docker compose --profile dev up
|
||||
```
|
||||
|
||||
**访问服务**:
|
||||
- 前端:http://localhost:5173
|
||||
- 后端:http://localhost:8080
|
||||
- API 文档:http://localhost:8080/api/v1
|
||||
|
||||
**默认账号**:
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin123`
|
||||
|
||||
#### 方式 2: 生产模式(用于完整功能测试)
|
||||
|
||||
**特点**:
|
||||
- ✅ 真实数据库
|
||||
- ✅ 完整功能
|
||||
- ✅ 生产环境配置
|
||||
|
||||
```bash
|
||||
# 使用 Make
|
||||
make docker-prod
|
||||
|
||||
# 或直接使用 Docker Compose
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**访问服务**:
|
||||
- 前端:http://localhost:3000
|
||||
- 后端:http://localhost:8080
|
||||
- 数据库:localhost:5432
|
||||
|
||||
#### 方式 3: 独立测试单个服务
|
||||
|
||||
**测试后端**:
|
||||
```bash
|
||||
make docker-test-backend
|
||||
# 访问:http://localhost:8080
|
||||
```
|
||||
|
||||
**测试前端**:
|
||||
```bash
|
||||
make docker-test-frontend
|
||||
# 访问:http://localhost:3000
|
||||
```
|
||||
|
||||
### 第三步:验证服务
|
||||
|
||||
```bash
|
||||
# 检查后端健康状态
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 登录获取 token
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
|
||||
# 获取 registries 列表
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
```
|
||||
|
||||
### 第四步:开始开发
|
||||
|
||||
#### 修改后端代码
|
||||
|
||||
```bash
|
||||
# 编辑任意 Go 文件
|
||||
vim backend/cmd/api/main.go
|
||||
|
||||
# Air 会自动检测变化并重新编译(开发模式)
|
||||
# 查看日志确认重载
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend
|
||||
```
|
||||
|
||||
#### 修改前端代码
|
||||
|
||||
```bash
|
||||
# 编辑任意 React 组件
|
||||
vim frontend/src/App.tsx
|
||||
|
||||
# Vite HMR 会自动更新浏览器(开发模式)
|
||||
# 无需手动刷新页面
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 停止并删除容器
|
||||
make docker-down
|
||||
|
||||
# 或
|
||||
docker compose down
|
||||
|
||||
# 完全清理(包括数据卷)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 常用命令速查
|
||||
|
||||
### Docker 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `make docker-dev` | 启动开发环境 |
|
||||
| `make docker-prod` | 启动生产环境 |
|
||||
| `make docker-test-backend` | 测试后端 |
|
||||
| `make docker-test-frontend` | 测试前端 |
|
||||
| `make docker-logs` | 查看所有日志 |
|
||||
| `make docker-down` | 停止所有服务 |
|
||||
| `make docker-build` | 重新构建镜像 |
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 所有服务
|
||||
docker compose logs -f
|
||||
|
||||
# 只看后端
|
||||
docker compose logs -f backend
|
||||
|
||||
# 只看前端
|
||||
docker compose logs -f frontend
|
||||
|
||||
# 只看最后 100 行
|
||||
docker compose logs --tail=100
|
||||
```
|
||||
|
||||
### 进入容器
|
||||
|
||||
```bash
|
||||
# 进入后端容器
|
||||
docker compose exec backend sh
|
||||
|
||||
# 进入前端容器
|
||||
docker compose exec frontend sh
|
||||
|
||||
# 进入数据库容器
|
||||
docker compose exec postgres psql -U postgres -d ocdp
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有
|
||||
docker compose restart
|
||||
|
||||
# 重启后端
|
||||
docker compose restart backend
|
||||
|
||||
# 重启前端
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用场景指南
|
||||
|
||||
### 场景 1: 我要开发新功能
|
||||
|
||||
```bash
|
||||
# 1. 启动开发环境
|
||||
make docker-dev
|
||||
|
||||
# 2. 修改代码(自动热重载)
|
||||
|
||||
# 3. 查看日志
|
||||
make docker-logs
|
||||
|
||||
# 4. 测试功能
|
||||
# 访问 http://localhost:5173
|
||||
|
||||
# 5. 停止
|
||||
make docker-down
|
||||
```
|
||||
|
||||
### 场景 2: 我要测试完整功能
|
||||
|
||||
```bash
|
||||
# 1. 启动生产环境(包含数据库)
|
||||
make docker-prod
|
||||
|
||||
# 2. 访问前端
|
||||
# http://localhost:3000
|
||||
|
||||
# 3. 停止
|
||||
make docker-down
|
||||
```
|
||||
|
||||
### 场景 3: 我只想测试后端 API
|
||||
|
||||
```bash
|
||||
# 1. 启动后端 Mock
|
||||
make docker-test-backend-bg
|
||||
|
||||
# 2. 测试 API
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
|
||||
# 3. 停止
|
||||
docker compose -f docker-compose.mock.yml down
|
||||
```
|
||||
|
||||
### 场景 4: 我只想测试前端界面
|
||||
|
||||
```bash
|
||||
# 1. 启动前端 Mock
|
||||
make docker-test-frontend-bg
|
||||
|
||||
# 2. 访问前端
|
||||
# http://localhost:3000
|
||||
|
||||
# 3. 停止
|
||||
docker compose -f docker-compose.mock.yml down
|
||||
```
|
||||
|
||||
### 场景 5: 代码修改不生效
|
||||
|
||||
```bash
|
||||
# 1. 停止所有服务
|
||||
make docker-down
|
||||
|
||||
# 2. 重新构建镜像
|
||||
make docker-build --no-cache
|
||||
|
||||
# 3. 重新启动
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Q1: 端口被占用怎么办?
|
||||
|
||||
**问题**:启动时报错 "port is already allocated"
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 查看占用端口的进程
|
||||
sudo lsof -i :8080
|
||||
sudo lsof -i :5173
|
||||
sudo lsof -i :3000
|
||||
|
||||
# 杀掉进程或修改 docker-compose.yml 中的端口映射
|
||||
```
|
||||
|
||||
### Q2: 容器启动失败
|
||||
|
||||
**查看详细错误**:
|
||||
```bash
|
||||
docker compose logs backend
|
||||
docker compose logs frontend
|
||||
```
|
||||
|
||||
**常见原因**:
|
||||
- 端口冲突
|
||||
- 依赖服务未就绪
|
||||
- 配置错误
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 清理并重新启动
|
||||
docker compose down -v
|
||||
docker compose build --no-cache
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Q3: 热重载不工作
|
||||
|
||||
**后端**:
|
||||
```bash
|
||||
# 确认 Air 是否运行
|
||||
docker compose logs backend | grep "air"
|
||||
|
||||
# 检查文件挂载
|
||||
docker compose exec backend ls -la /app
|
||||
```
|
||||
|
||||
**前端**:
|
||||
```bash
|
||||
# 确认 Vite 是否运行
|
||||
docker compose logs frontend | grep "VITE"
|
||||
|
||||
# 重启前端
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
### Q4: 数据库连接失败
|
||||
|
||||
**检查**:
|
||||
```bash
|
||||
# 数据库是否运行
|
||||
docker compose ps postgres
|
||||
|
||||
# 健康检查
|
||||
docker compose exec postgres pg_isready
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 确保使用生产模式(`docker compose up`)
|
||||
- 等待数据库健康检查通过(约 10-20 秒)
|
||||
- 检查 `DATABASE_URL` 环境变量
|
||||
|
||||
### Q5: 前端无法连接后端
|
||||
|
||||
**检查**:
|
||||
```bash
|
||||
# 后端是否运行
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 网络连接
|
||||
docker network inspect ocdp-network
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 确认后端服务运行正常
|
||||
- 检查 `VITE_API_BASE_URL` 环境变量
|
||||
- 检查浏览器控制台错误
|
||||
|
||||
---
|
||||
|
||||
## 📖 进一步学习
|
||||
|
||||
### 详细文档
|
||||
|
||||
- [Docker 服务架构](./DOCKER_SERVICES.md) - 完整的服务说明
|
||||
- [开发指南](./docs/development/specification.md) - 开发规范
|
||||
- [API 文档](./backend/docs/openapi.yaml) - OpenAPI 规范
|
||||
- [部署指南](./docs/deployment/docker-guide.md) - 生产部署
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
ocdp-go/
|
||||
├── backend/ # Go 后端服务
|
||||
├── frontend/ # React 前端应用
|
||||
├── api/ # OpenAPI 规范
|
||||
├── docs/ # 项目文档
|
||||
├── docker-compose.yml # 生产模式
|
||||
├── docker-compose.dev.yml # 开发模式
|
||||
├── docker-compose.mock.yml # Mock 模式
|
||||
└── Makefile # 便捷命令
|
||||
```
|
||||
|
||||
### 架构说明
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Frontend │────▶│ Backend │
|
||||
│ (React) │◀────│ (Go API) │
|
||||
└─────────────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ PostgreSQL │
|
||||
│ Database │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 开始使用
|
||||
|
||||
现在你已经准备好开始使用 OCDP 了!
|
||||
|
||||
**推荐的开发流程**:
|
||||
1. ✅ 使用 `make docker-dev` 启动开发环境
|
||||
2. ✅ 修改代码(自动热重载)
|
||||
3. ✅ 使用浏览器测试功能
|
||||
4. ✅ 使用 `make docker-test-backend` 测试后端 API
|
||||
5. ✅ 使用 `make docker-prod` 进行完整测试
|
||||
|
||||
**需要帮助?**
|
||||
- 查看 [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) 了解详细配置
|
||||
- 查看 [GitHub Issues](https://github.com/your-repo/issues) 报告问题
|
||||
- 查看项目文档获取更多信息
|
||||
|
||||
Happy Coding! 🚀
|
||||
|
||||
418
README.md
418
README.md
@ -1,201 +1,267 @@
|
||||
# OCDP - Open Cloud Deployment Platform
|
||||
|
||||
开源云原生部署平台,支持从 Harbor(或其他 OCI Registry)拉取 Helm Charts 并一键部署到多个 Kubernetes 集群。
|
||||
OCDP 是一个面向 Kubernetes 的大模型推理部署平台。当前核心场景是:用户在页面选择 Harbor 中的 `vllm-serve` Helm Chart,填写实例名称、命名空间和 values 后,后端从 Harbor 拉取封装好的 OCI Helm Chart,并通过 Helm SDK 部署到已配置好的 Kubernetes 集群。
|
||||
|
||||
## 功能特性
|
||||
## 当前能力
|
||||
|
||||
- **多集群管理** - 支持多个 kubeconfig,管理多个 K8S 集群
|
||||
- **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
|
||||
- **多租户支持** - Workspace 隔离,管理员和普通用户角色
|
||||
- **存储后端** - NFS/PV/hostPath 存储配置管理
|
||||
- **Chart 引用** - 管理可用的 Helm Charts
|
||||
- **Values 模板** - 版本控制、支持回滚的配置模板
|
||||
- **一键部署** - 从 Harbor 拉取 Charts 部署到指定集群
|
||||
- **实例管理** - 支持升级、回滚、卸载 Helm Release
|
||||
- **状态监控** - 实时同步 Helm Release 状态
|
||||
- Registry 管理:保存 Harbor / OCI Registry 地址与凭据,敏感字段加密入库。
|
||||
- Artifact 浏览:通过 Harbor v2.0 API 浏览当前凭据可见的项目、repositories 和 chart tags,避免依赖 `/v2/_catalog` 全局 catalog 权限。
|
||||
- 一键部署:从前端发起实例创建,后端拉取 Chart 并在目标集群执行 Helm install/upgrade/uninstall。
|
||||
- 集群管理:保存 Kubernetes API Server、CA、客户端证书或 token,用于后端连接集群。
|
||||
- 实例管理:查看部署状态、Helm revision、Service/Ingress 入口信息。
|
||||
- 认证:内置 JWT 登录,首次启动可通过 bootstrap 注入管理员账号。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.21+, Hexagonal Architecture |
|
||||
| 前端 | React 18, TypeScript, Next.js, TailwindCSS |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 网关 | Nginx |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Docker Compose 启动(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 完全停止并清理现有容器
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml down -v
|
||||
|
||||
# 2. 启动所有服务(PostgreSQL + Backend + Frontend + Nginx)
|
||||
ALLOWED_DEV_ORIGINS="http://10.6.80.114:3000" \
|
||||
NEXT_PUBLIC_API_URL="http://10.6.80.114:8080/api/v1" \
|
||||
BACKEND_PORT=8080 \
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d
|
||||
|
||||
# 3. 查看服务状态
|
||||
docker ps
|
||||
|
||||
# 4. 访问
|
||||
# 前端: http://10.6.80.114
|
||||
# 后端: http://10.6.80.114:8080/api/v1
|
||||
# 默认账号: admin / admin123
|
||||
|
||||
# 停止服务
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml down
|
||||
```
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 1. 确保 PostgreSQL 运行在 localhost:5432
|
||||
# 启动 PostgreSQL 容器:
|
||||
# docker run -d --name ocdp-postgres -e POSTGRES_DB=ocdp -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:15
|
||||
|
||||
# 2. 初始化数据库(首次运行)
|
||||
# 方法一:手动执行 SQL
|
||||
cat backend/scripts/init-db.sql | docker exec -i ocdp-postgres psql -U postgres -d ocdp
|
||||
|
||||
# 方法二:使用 Make(需确保 PostgreSQL 容器名为 ocdp-postgres)
|
||||
make db-init
|
||||
|
||||
# 3. 启动后端(需要设置环境变量)
|
||||
cd backend && \
|
||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable" \
|
||||
JWT_SECRET="test-jwt-secret-key" \
|
||||
ENCRYPTION_KEY="test-encryption-key-32-bytes-long" \
|
||||
PORT=8081 \
|
||||
ALLOWED_DEV_ORIGINS="10.6.80.114,localhost,127.0.0.1" \
|
||||
go run cmd/api/main.go
|
||||
|
||||
# 4. 启动前端(需要 Node.js 20)
|
||||
source ~/.nvm/nvm.sh && nvm use 20
|
||||
cd frontend && NEXT_PUBLIC_API_URL=http://10.6.80.114:8081/api/v1 npm run dev
|
||||
|
||||
# 5. 从外部访问
|
||||
# 本机访问: http://localhost:3000
|
||||
# 外部访问: http://10.6.80.114:3000
|
||||
# 默认账号: admin / admin123
|
||||
|
||||
# ===== 一键启动脚本 =====
|
||||
# 启动所有服务(后台运行)
|
||||
./start.sh
|
||||
|
||||
# 停止所有服务
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
### 生产环境(Docker Compose)
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
make run-2
|
||||
|
||||
# 停止服务
|
||||
make clean-2
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| DATABASE_URL | postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable | 数据库连接串 |
|
||||
| PORT | 8080 | 后端端口 |
|
||||
| BACKEND_PORT | 8080 | Docker 映射端口 |
|
||||
| JWT_SECRET | change-me-in-production | JWT 密钥 |
|
||||
| ENCRYPTION_KEY | change-me-32-bytes-long-key-here | 加密密钥 |
|
||||
| ALLOWED_DEV_ORIGINS | http://10.6.80.114:3000 | 允许的跨域来源(外部访问时需要配置)|
|
||||
| NEXT_PUBLIC_API_URL | http://10.6.80.114:8080/api/v1 | 前端调用的API地址 |
|
||||
|
||||
### 配置文件
|
||||
|
||||
项目根目录 `.env` 文件包含默认配置:
|
||||
- Kubernetes 集群配置
|
||||
- Harbor 仓库信息
|
||||
- NFS 存储配置
|
||||
|
||||
## 访问
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端 (Nginx) | http://10.6.80.114 |
|
||||
| 后端 API | http://10.6.80.114:8080/api/v1 |
|
||||
| API 文档 | http://10.6.80.114:8080/api/docs |
|
||||
|
||||
**默认账号**: `admin` / `admin123`
|
||||
- 后端:Go 1.24,Gorilla Mux,Hexagonal Architecture,PostgreSQL,ORAS SDK,Helm SDK,Kubernetes client-go。
|
||||
- 前端:React 18,TypeScript,Vite,TailwindCSS。
|
||||
- 部署:Docker Compose,Nginx 静态文件与 `/api` 反向代理,PostgreSQL 持久化。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
```text
|
||||
ocdp-go/
|
||||
├── backend/ # Go 后端 (Hexagonal Architecture)
|
||||
│ ├── cmd/api/ # 入口点
|
||||
│ ├── internal/
|
||||
│ │ ├── adapter/ # 适配器层 (HTTP, Persistence)
|
||||
│ │ ├── domain/ # 领域层 (Entity, Service, Repository)
|
||||
│ │ └── bootstrap/ # 初始化和种子数据
|
||||
│ └── scripts/ # 脚本 (init-db.sql)
|
||||
├── frontend/ # Next.js 前端
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # 页面路由
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ └── lib/ # 工具库 (API, types, auth)
|
||||
│ └── .env.local # 前端环境配置
|
||||
├── infra/nginx/ # Nginx 配置
|
||||
├── docker-compose.yml # 主配置
|
||||
├── backend/docker-compose.yml # 后端配置
|
||||
└── Makefile # 构建命令
|
||||
├── backend/ # Go 后端
|
||||
│ ├── cmd/api/ # API 入口
|
||||
│ ├── internal/adapter/input/ # HTTP REST handlers / DTO
|
||||
│ ├── internal/adapter/output/ # PostgreSQL / ORAS / Helm / K8s 实现
|
||||
│ ├── internal/domain/ # Entity / Repository interface / Service
|
||||
│ └── internal/bootstrap/ # 首次启动数据注入
|
||||
├── frontend/ # React + Vite 前端
|
||||
├── infra/nginx/ # Nginx 网关配置和 TLS 证书
|
||||
├── docker-compose.yml # 本地完整部署:PostgreSQL + Backend + 前端 build + Nginx
|
||||
├── backend/docker-compose.yml # PostgreSQL + Backend + pgAdmin
|
||||
├── Makefile # 推荐入口:install / run-2 / docker-dev / docker-down
|
||||
└── tasks/ # Agent 工作记录
|
||||
```
|
||||
|
||||
## 核心 API
|
||||
## 后端部署链路
|
||||
|
||||
| 模块 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| 认证 | POST /api/v1/auth/login | 用户登录 |
|
||||
| 用户 | GET/POST /api/v1/users | 用户管理 |
|
||||
| Workspaces | GET/POST /api/v1/workspaces | 工作空间管理 |
|
||||
| Clusters | GET/POST /api/v1/clusters | 集群管理 |
|
||||
| Registries | GET/POST /api/v1/registries | 镜像仓库管理 |
|
||||
| Storage | GET/POST /api/v1/storage-backends | 存储后端管理 |
|
||||
| Chart References | GET/POST /api/v1/chart-references | Chart 引用管理 |
|
||||
| Values Templates | GET/POST /api/v1/values-templates | 配置模板管理 |
|
||||
| Instances | GET/POST/DELETE /api/v1/instances | 实例部署管理 |
|
||||
1. 前端调用 `POST /api/v1/clusters/{clusterId}/instances`,提交 `name`、`namespace`、`registryId`、`repository`、`tag` 和可选 `values`。
|
||||
2. 后端 `InstanceService.CreateInstance` 校验集群、Registry 和实例名唯一性,创建 pending 记录。
|
||||
3. Chart 浏览使用 Harbor v2.0 API;实际部署时后端使用 ORAS SDK 访问 Harbor,将指定 repository/tag 的 Helm Chart layer 下载到 `/tmp/charts/{chart}-{version}.tgz`。
|
||||
4. 后端用数据库中保存的集群凭据生成临时 kubeconfig。
|
||||
5. Helm SDK 加载本地 chart 包,并对目标集群执行 `install`;后续通过 Helm status 同步实例状态。
|
||||
6. 删除、升级和回滚实例同样通过 Helm SDK 操作目标集群。
|
||||
|
||||
## 权限模型
|
||||
## 部署前准备
|
||||
|
||||
- **Admin** - 管理员,可管理所有资源和用户
|
||||
- **User** - 普通用户,仅可访问所属 Workspace 的资源
|
||||
需要本机已安装:
|
||||
|
||||
## 开发命令
|
||||
- Docker
|
||||
- Docker Compose v2 或更高版本
|
||||
- Make,可选;没有 Make 时可直接执行 Compose 命令
|
||||
|
||||
根目录 `.env` 用于开发环境启动时注入端口、数据库、初始账号、Harbor 和 Kubernetes 集群。它是开发/测试 bootstrap 数据,不是长期配置中心;系统启动后建议在页面里维护 Registry 和 Cluster。不要提交真实 `.env`。
|
||||
|
||||
关键变量如下,实际值以你的 `.env` 为准:
|
||||
|
||||
```dotenv
|
||||
# 登录账号 bootstrap
|
||||
BOOTSTRAP_ADMIN_USER=admin
|
||||
BOOTSTRAP_ADMIN_PASS=change-me
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Harbor bootstrap
|
||||
BOOTSTRAP_REGISTRY_NAME=harbor
|
||||
BOOTSTRAP_REGISTRY_URL=https://harbor.example.com
|
||||
BOOTSTRAP_REGISTRY_DESC=Harbor Registry
|
||||
# 推荐使用 Harbor robot 账号,只授予目标项目 pull/read 权限
|
||||
BOOTSTRAP_REGISTRY_ROBOT_USER='robot$project+ocdp'
|
||||
BOOTSTRAP_REGISTRY_ROBOT_PASS='robot-token'
|
||||
|
||||
# 可选 fallback;未配置 ROBOT 变量时才会使用
|
||||
BOOTSTRAP_REGISTRY_USER=admin-or-user
|
||||
BOOTSTRAP_REGISTRY_PASS=change-me
|
||||
BOOTSTRAP_REGISTRY_INSECURE=false
|
||||
|
||||
# Kubernetes 集群 bootstrap,名称列表用逗号分隔
|
||||
BOOTSTRAP_CLUSTERS=cluster1,cluster2
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_HOST=https://x.x.x.x:6443
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_DESC=GPU Cluster 1
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CA=base64-ca-data
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CERT=base64-client-cert-data
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_KEY=base64-client-key-data
|
||||
|
||||
# 如使用 token,可配置 TOKEN;CERT/KEY 可按实际鉴权方式留空
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_HOST=https://x.x.x.x:6443
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_TOKEN=token-value
|
||||
|
||||
# 服务端口,默认使用高位端口避免和本机其他项目冲突
|
||||
WEB_HTTP_PORT=18080
|
||||
WEB_HTTPS_PORT=18443
|
||||
BACKEND_PORT=18081
|
||||
POSTGRES_PORT=15432
|
||||
|
||||
# 安全与数据库
|
||||
JWT_SECRET=replace-with-a-strong-secret
|
||||
ENCRYPTION_KEY=replace-with-32-byte-key
|
||||
POSTGRES_DB=ocdp
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=replace-me
|
||||
|
||||
# 可选:Docker 构建后端时使用的 Go module proxy。
|
||||
# 国内网络建议保留默认值;如公司网络要求,也可改回 https://proxy.golang.org,direct。
|
||||
GOPROXY=https://goproxy.cn,direct
|
||||
GOSUMDB=sum.golang.google.cn
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `BOOTSTRAP_CONFIG_JSON` 优先级最高,适合把完整 bootstrap 配置作为 JSON 注入。
|
||||
- 没有 `BOOTSTRAP_CONFIG_JSON` 时,后端会读取 `BOOTSTRAP_*` 变量生成初始账号、Registry 和 Cluster。
|
||||
- 没有任何显式 bootstrap 配置时,后端不会预注入用户、Registry 或 Cluster;代码中不再保留真实 Harbor、admin 或集群 fallback。
|
||||
- 初始管理员必须显式配置 `BOOTSTRAP_ADMIN_USER` 和 `BOOTSTRAP_ADMIN_PASS`。如果只配置 Registry/Cluster 而未配置管理员账号,系统不会自动创建默认账号。
|
||||
- Registry bootstrap 凭据优先级为 `BOOTSTRAP_REGISTRY_ROBOT_USER/PASS`,然后才是 `BOOTSTRAP_REGISTRY_USER/PASS`。Harbor robot 账号需要能访问目标项目的 repositories 和 artifacts。
|
||||
- Harbor robot 用户名通常包含 `$`。本项目 Compose 已使用 raw `env_file` 传给后端;如果你在 shell 里临时 `export BOOTSTRAP_REGISTRY_ROBOT_USER=...`,请用单引号包住值,避免 shell 展开 `$project`。
|
||||
- 已存在同名用户、Registry 或 Cluster 时,bootstrap 会跳过,不会覆盖数据库里的记录。
|
||||
- `ENCRYPTION_KEY` 用于加密保存 Harbor 密码和集群凭据;生产环境首次启动后不要随意更换,否则旧数据无法解密。
|
||||
|
||||
## 推荐部署流程
|
||||
|
||||
当前推荐使用根目录 Makefile。`docker-dev`、`docker-prod`、`docker-up` 都是兼容旧文档的别名,实际会启动同一套完整 Docker Compose 栈:PostgreSQL、Backend、前端静态构建和 Nginx。
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
make dev # 同时启动前后端
|
||||
make dev-backend # 仅后端
|
||||
make dev-frontend # 仅前端
|
||||
# 1. 在根目录检查 .env
|
||||
ls .env
|
||||
|
||||
# 数据库操作
|
||||
make db-init # 初始化数据库
|
||||
make db-reset # 重置数据库
|
||||
make db-shell # 打开数据库 shell
|
||||
# 2. 可选:安装本地依赖。只部署 Docker 栈时不是必须,但这个命令可用。
|
||||
make install
|
||||
|
||||
# Docker 构建
|
||||
make build # 构建所有镜像
|
||||
make build-backend # 构建后端镜像
|
||||
make build-frontend # 构建前端镜像
|
||||
# 3. 如果默认高位端口仍被其他项目占用,再临时换端口
|
||||
export WEB_HTTP_PORT=18080
|
||||
export WEB_HTTPS_PORT=18443
|
||||
export BACKEND_PORT=18081
|
||||
export POSTGRES_PORT=15432
|
||||
|
||||
# 日志和调试
|
||||
make logs # 查看所有日志
|
||||
make logs-backend # 后端日志
|
||||
make stop # 停止开发服务器
|
||||
# 4. 构建并后台启动完整栈
|
||||
make run-2
|
||||
|
||||
# 兼容旧文档,也可以执行:
|
||||
make docker-dev
|
||||
make docker-prod
|
||||
|
||||
# 5. 查看服务
|
||||
make docker-ps
|
||||
```
|
||||
|
||||
## License
|
||||
访问地址:
|
||||
|
||||
MIT
|
||||
- 前端入口:http://localhost:${WEB_HTTP_PORT:-18080}
|
||||
- 后端健康检查:http://localhost:${BACKEND_PORT:-18081}/health
|
||||
- Swagger UI:http://localhost:${BACKEND_PORT:-18081}/api/docs
|
||||
- Nginx 健康检查:http://localhost:${WEB_HTTP_PORT:-18080}/healthz
|
||||
|
||||
没有 Make 时,直接用根目录 Compose 文件即可。注意要加 `--build`,因为后端镜像和前端静态资源需要构建:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d postgres backend nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
如果直接执行 `docker compose up`,Compose 也会使用同一个完整栈;但在代码或 Dockerfile 改动后建议显式加 `--build`,避免复用旧镜像。
|
||||
|
||||
## 验证部署
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:${BACKEND_PORT:-18081}/health
|
||||
curl http://localhost:${WEB_HTTP_PORT:-18080}/healthz
|
||||
|
||||
# 登录,返回 token。把 password 替换成 .env 里的 BOOTSTRAP_ADMIN_PASS。
|
||||
curl -s -X POST http://localhost:${BACKEND_PORT:-18081}/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"<BOOTSTRAP_ADMIN_PASS>"}'
|
||||
|
||||
# 查看 bootstrap 是否生效,需要带 Bearer token
|
||||
curl http://localhost:${BACKEND_PORT:-18081}/api/v1/registries \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
curl http://localhost:${BACKEND_PORT:-18081}/api/v1/clusters \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
页面验证:
|
||||
|
||||
1. 打开前端入口并登录。
|
||||
2. 进入 Chart Browser,确认能看到 Harbor 中的 `vllm-serve` 或 nginx chart repository。当前默认只展示可部署 Helm chart。
|
||||
3. 选择 chart tag,点击 Launch。
|
||||
4. 选择目标集群、命名空间,填写实例名和 values。values 支持 schema 表单或 YAML;YAML 会在前端校验,并由后端解析为 Helm values map。
|
||||
5. 提交后到实例页面查看状态;后端会异步安装并同步 Helm 状态。
|
||||
|
||||
命令行 smoke test:
|
||||
|
||||
```bash
|
||||
# 只验证登录、Registry health、Harbor chart 浏览和 values schema
|
||||
BASE_URL=http://localhost:${BACKEND_PORT:-18081}/api/v1 \
|
||||
ADMIN_USER="${BOOTSTRAP_ADMIN_USER:-admin}" \
|
||||
ADMIN_PASS="<BOOTSTRAP_ADMIN_PASS>" \
|
||||
./test/current-platform-smoke.sh
|
||||
|
||||
# 允许真实部署时,会创建测试 release 并在结束后调用平台删除
|
||||
RUN_DEPLOY_TEST=true \
|
||||
TEST_NAMESPACE=ocdp-smoke \
|
||||
TEST_RELEASE=ocdp-smoke-nginx \
|
||||
BASE_URL=http://localhost:${BACKEND_PORT:-18081}/api/v1 \
|
||||
ADMIN_PASS="<BOOTSTRAP_ADMIN_PASS>" \
|
||||
./test/current-platform-smoke.sh
|
||||
```
|
||||
|
||||
## 常用运维命令
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
make docker-logs
|
||||
|
||||
# 重启后端
|
||||
docker compose restart backend
|
||||
|
||||
# 如果后端容器被重建过,Nginx 可能仍缓存旧 upstream IP;只需重启本项目 Nginx
|
||||
docker compose restart nginx
|
||||
|
||||
# 停止本项目服务,但保留数据卷
|
||||
make docker-down
|
||||
|
||||
# 清理本项目容器和数据卷,谨慎使用
|
||||
make clean-2
|
||||
```
|
||||
|
||||
## 本地开发与测试
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test ./...
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Mock 后端仍可通过 `backend/docker-compose.yml` 的 `mock` profile 启动:
|
||||
|
||||
```bash
|
||||
docker compose -f backend/docker-compose.yml --profile mock up -d backend-mock
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要为了端口冲突停止其他项目;优先通过 `WEB_HTTP_PORT`、`WEB_HTTPS_PORT`、`BACKEND_PORT`、`POSTGRES_PORT` 换端口。当前默认端口已经是 `18080/18443/18081/15432`。
|
||||
- 如果旧文档提到 `make docker-dev`、`make docker-prod`,现在这些命令仍可用,都会启动同一套 Docker 栈。
|
||||
- 如果之前用旧配置启动失败过,PostgreSQL 卷里可能残留旧的加密数据,表现为 `/api/v1/clusters` 或 `/api/v1/registries` 解密失败。开发/重装环境可执行 `make clean-2 && make docker-dev` 重新初始化;生产环境不要直接删卷,应先备份数据库。
|
||||
- `vllm-serve` 必须以 Helm Chart OCI artifact 的形式存在于 Harbor 中;后端会寻找 Helm Chart layer 并保存为 `.tgz`。
|
||||
- Harbor 浏览使用 `/api/v2.0/projects`、project repositories 和 artifacts API。若 robot 账号无法列项目或 artifacts,页面会显示明确错误;请检查 Harbor 项目成员/robot 权限,而不是给普通用户开放全局 catalog。
|
||||
- values YAML 已按 YAML 解析;顶层必须是 mapping,例如 `replicaCount: 1`。
|
||||
- Nginx 默认同时监听 HTTP 和 HTTPS,证书位于 `infra/nginx/certs/`,生产环境应替换为正式证书。
|
||||
- `make clean-2` 会删除本项目 Compose 卷,包括 PostgreSQL 数据;只想停服务时使用 `docker compose ... down --remove-orphans`。
|
||||
|
||||
## API 文档
|
||||
|
||||
- OpenAPI YAML:[backend/docs/openapi.yaml](./backend/docs/openapi.yaml)
|
||||
- 运行后 Swagger UI:`/api/docs`
|
||||
|
||||
127
START_BACKEND.md
Normal file
127
START_BACKEND.md
Normal file
@ -0,0 +1,127 @@
|
||||
# 启动和更新后端服务指南
|
||||
|
||||
## 方式一:使用 Makefile(推荐)
|
||||
|
||||
### 启动完整服务(前端 + 后端 + Nginx)
|
||||
```bash
|
||||
make run-2
|
||||
```
|
||||
|
||||
这会:
|
||||
1. 重新构建前端静态资源
|
||||
2. 重新构建后端镜像
|
||||
3. 启动 PostgreSQL、Backend 和 Nginx 服务
|
||||
|
||||
### 停止服务
|
||||
```bash
|
||||
make clean-2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式二:只更新后端服务
|
||||
|
||||
### 1. 停止服务
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||
```
|
||||
|
||||
### 2. 重新构建并启动后端
|
||||
```bash
|
||||
# 设置环境变量(必须!)
|
||||
export COMPOSE_PROJECT_NAME=ocdp
|
||||
export ADAPTER_MODE=production
|
||||
export BACKEND_BUILD_CONTEXT=$(pwd)/backend
|
||||
export BACKEND_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile
|
||||
export BACKEND_MOCK_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile.mock
|
||||
export INIT_DB_SQL_PATH=$(pwd)/backend/scripts/init-db.sql
|
||||
|
||||
# 重新构建后端镜像
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend build backend
|
||||
|
||||
# 启动服务
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d postgres backend nginx
|
||||
```
|
||||
|
||||
### 3. 或者使用一行命令(强制重建)
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build --force-recreate backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:只重启后端容器(不重新构建)
|
||||
|
||||
```bash
|
||||
# 重启后端容器
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend restart backend
|
||||
|
||||
# 或者先停止再启动
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend stop backend
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend start backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式四:查看日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f
|
||||
|
||||
# 只查看后端日志
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式五:开发模式(只启动数据库,本地运行后端)
|
||||
|
||||
```bash
|
||||
# 只启动 PostgreSQL
|
||||
docker compose -f backend/docker-compose.yml up -d postgres
|
||||
|
||||
# 然后在本地运行后端
|
||||
cd backend
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用命令速查
|
||||
|
||||
```bash
|
||||
# 查看运行中的服务
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend ps
|
||||
|
||||
# 查看后端容器状态
|
||||
docker ps | grep ocdp-backend
|
||||
|
||||
# 进入后端容器
|
||||
docker exec -it ocdp-backend sh
|
||||
|
||||
# 查看后端健康状态
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 停止所有服务
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||
|
||||
# 停止并删除数据卷(清理数据)
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速更新后端(推荐流程)
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||
|
||||
# 2. 重新构建并启动(使用 Makefile)
|
||||
make run-2
|
||||
|
||||
# 或者手动执行
|
||||
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build
|
||||
```
|
||||
|
||||
355
USAGE_GUIDE.md
Normal file
355
USAGE_GUIDE.md
Normal file
@ -0,0 +1,355 @@
|
||||
# OCDP 使用指南
|
||||
|
||||
## 🎯 统一的 docker-compose.yml
|
||||
|
||||
现在所有配置都整合在一个文件中,使用 **profiles** 区分不同运行模式。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式 1: 使用 Make(推荐)
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
make docker-dev
|
||||
|
||||
# 生产模式
|
||||
make docker-prod
|
||||
|
||||
# 测试后端
|
||||
make docker-test-backend
|
||||
|
||||
# 测试前端
|
||||
make docker-test-frontend
|
||||
```
|
||||
|
||||
### 方式 2: 使用 Docker Compose
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
docker compose --profile dev up
|
||||
|
||||
# 生产模式
|
||||
docker compose --profile production up
|
||||
|
||||
# Mock 测试模式(后端)
|
||||
docker compose --profile mock up backend-mock
|
||||
|
||||
# Mock 测试模式(前端)
|
||||
docker compose --profile mock up frontend-mock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 三种运行模式
|
||||
|
||||
### 1. 开发模式(Dev Profile)
|
||||
|
||||
**特点**:
|
||||
- ✅ 后端 Mock 模式(无需数据库)
|
||||
- ✅ 热重载(Air + Vite HMR)
|
||||
- ✅ 快速启动
|
||||
|
||||
**启动命令**:
|
||||
```bash
|
||||
# 前台运行
|
||||
docker compose --profile dev up
|
||||
|
||||
# 后台运行
|
||||
docker compose --profile dev up -d
|
||||
|
||||
# 或使用 Make
|
||||
make docker-dev
|
||||
make docker-dev-bg
|
||||
```
|
||||
|
||||
**访问地址**:
|
||||
- 前端:http://localhost:5173
|
||||
- 后端:http://localhost:8080
|
||||
|
||||
**服务列表**:
|
||||
- `backend-dev` - 后端开发服务
|
||||
- `frontend-dev` - 前端开发服务
|
||||
|
||||
### 2. 生产模式(Production Profile)
|
||||
|
||||
**特点**:
|
||||
- ✅ 真实数据库(PostgreSQL + Redis)
|
||||
- ✅ 完整功能
|
||||
- ✅ 生产环境配置
|
||||
|
||||
**启动命令**:
|
||||
```bash
|
||||
# 后台运行
|
||||
docker compose --profile production up -d
|
||||
|
||||
# 或使用 Make
|
||||
make docker-prod
|
||||
make docker-up
|
||||
```
|
||||
|
||||
**访问地址**:
|
||||
- 前端:http://localhost:3000
|
||||
- 后端:http://localhost:8080
|
||||
- 数据库:localhost:5432
|
||||
|
||||
**服务列表**:
|
||||
- `postgres` - PostgreSQL 数据库
|
||||
- `redis` - Redis 缓存
|
||||
- `backend-prod` - 后端生产服务
|
||||
- `frontend-prod` - 前端生产服务
|
||||
|
||||
### 3. Mock 测试模式(Mock Profile)
|
||||
|
||||
**特点**:
|
||||
- ✅ 独立测试单个服务
|
||||
- ✅ 无外部依赖
|
||||
- ✅ 快速启动
|
||||
|
||||
**测试后端**:
|
||||
```bash
|
||||
# 前台运行
|
||||
docker compose --profile mock up backend-mock
|
||||
|
||||
# 后台运行
|
||||
docker compose --profile mock up -d backend-mock
|
||||
|
||||
# 或使用 Make
|
||||
make docker-test-backend
|
||||
make docker-test-backend-bg
|
||||
```
|
||||
|
||||
**测试前端**:
|
||||
```bash
|
||||
# 前台运行
|
||||
docker compose --profile mock up frontend-mock
|
||||
|
||||
# 后台运行
|
||||
docker compose --profile mock up -d frontend-mock
|
||||
|
||||
# 或使用 Make
|
||||
make docker-test-frontend
|
||||
make docker-test-frontend-bg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 常用操作
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看所有日志
|
||||
docker compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker compose logs -f backend-dev
|
||||
docker compose logs -f frontend-dev
|
||||
docker compose logs -f backend-prod
|
||||
docker compose logs -f backend-mock
|
||||
|
||||
# 或使用 Make
|
||||
make docker-logs
|
||||
make docker-logs-backend
|
||||
make docker-logs-frontend
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker compose down
|
||||
|
||||
# 停止特定 profile 的服务
|
||||
docker compose --profile dev down
|
||||
docker compose --profile production down
|
||||
docker compose --profile mock down
|
||||
|
||||
# 或使用 Make
|
||||
make docker-down
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有运行的服务
|
||||
docker compose restart
|
||||
|
||||
# 重启特定服务
|
||||
docker compose restart backend-dev
|
||||
docker compose restart frontend-dev
|
||||
|
||||
# 或使用 Make
|
||||
make docker-restart
|
||||
make docker-restart-backend
|
||||
make docker-restart-frontend
|
||||
```
|
||||
|
||||
### 构建镜像
|
||||
|
||||
```bash
|
||||
# 构建所有镜像
|
||||
docker compose build
|
||||
|
||||
# 无缓存构建
|
||||
docker compose build --no-cache
|
||||
|
||||
# 或使用 Make
|
||||
make docker-build
|
||||
make docker-build-no-cache
|
||||
```
|
||||
|
||||
### 查看状态
|
||||
|
||||
```bash
|
||||
# 查看运行的服务
|
||||
docker compose ps
|
||||
|
||||
# 查看详细状态
|
||||
make docker-status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发工具
|
||||
|
||||
### 启动 pgAdmin 和 Swagger UI
|
||||
|
||||
```bash
|
||||
# 需要先启动生产模式
|
||||
docker compose --profile production --profile tools up -d
|
||||
|
||||
# 或使用 Make
|
||||
make docker-tools
|
||||
```
|
||||
|
||||
**访问地址**:
|
||||
- pgAdmin:http://localhost:5050
|
||||
- Swagger UI:http://localhost:8081
|
||||
|
||||
---
|
||||
|
||||
## 📊 服务对比表
|
||||
|
||||
| 特性 | 开发模式 | 生产模式 | Mock 模式 |
|
||||
|------|---------|---------|----------|
|
||||
| **Profile** | `dev` | `production` | `mock` |
|
||||
| **后端服务** | `backend-dev` | `backend-prod` | `backend-mock` |
|
||||
| **前端服务** | `frontend-dev` | `frontend-prod` | `frontend-mock` |
|
||||
| **数据库** | ❌ Mock | ✅ PostgreSQL | ❌ Mock |
|
||||
| **前端端口** | 5173 | 3000 | 3000 |
|
||||
| **热重载** | ✅ | ❌ | ❌ |
|
||||
| **启动时间** | ~15秒 | ~30秒 | ~5秒 |
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用场景示例
|
||||
|
||||
### 场景 1: 日常开发
|
||||
|
||||
```bash
|
||||
# 1. 启动开发环境
|
||||
make docker-dev
|
||||
|
||||
# 2. 修改代码(自动热重载)
|
||||
|
||||
# 3. 查看日志
|
||||
make docker-logs
|
||||
|
||||
# 4. 停止
|
||||
make docker-down
|
||||
```
|
||||
|
||||
### 场景 2: 测试后端 API
|
||||
|
||||
```bash
|
||||
# 1. 启动后端 Mock
|
||||
make docker-test-backend-bg
|
||||
|
||||
# 2. 测试 API
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
|
||||
# 3. 停止
|
||||
docker compose --profile mock down
|
||||
```
|
||||
|
||||
### 场景 3: 生产环境部署
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
export JWT_SECRET="your-secret"
|
||||
export ENCRYPTION_KEY="your-32-byte-key"
|
||||
|
||||
# 2. 启动生产环境
|
||||
make docker-prod
|
||||
|
||||
# 3. 检查状态
|
||||
make docker-status
|
||||
|
||||
# 4. 启动管理工具
|
||||
make docker-tools
|
||||
```
|
||||
|
||||
### 场景 4: 完全重置
|
||||
|
||||
```bash
|
||||
# 1. 停止并删除所有容器和数据
|
||||
docker compose down -v
|
||||
|
||||
# 2. 删除镜像
|
||||
docker rmi $(docker images | grep ocdp | awk '{print $3}')
|
||||
|
||||
# 3. 重新构建
|
||||
make docker-build
|
||||
|
||||
# 4. 重新启动
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 环境变量
|
||||
|
||||
### 开发模式环境变量
|
||||
|
||||
后端自动使用:
|
||||
- `ADAPTER_MODE=mock`
|
||||
- `JWT_SECRET=dev-secret-key`
|
||||
|
||||
### 生产模式环境变量
|
||||
|
||||
可通过 `.env` 文件或导出环境变量设置:
|
||||
```bash
|
||||
export JWT_SECRET="your-production-secret"
|
||||
export ENCRYPTION_KEY="your-production-encryption-key-32-bytes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [README.md](./README.md) - 项目概述
|
||||
- [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) - 详细架构说明
|
||||
- [COMMANDS_CHEATSHEET.md](./COMMANDS_CHEATSHEET.md) - 命令速查表
|
||||
- [QUICK_START.md](./QUICK_START.md) - 快速开始指南
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优势
|
||||
|
||||
通过整合到单个 `docker-compose.yml` 文件:
|
||||
|
||||
- ✅ **更简洁**:只需维护一个配置文件
|
||||
- ✅ **更清晰**:所有服务定义在同一处
|
||||
- ✅ **更灵活**:通过 profiles 轻松切换模式
|
||||
- ✅ **更易维护**:减少配置重复
|
||||
- ✅ **向后兼容**:Make 命令保持不变
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>简化配置,提升效率!🚀</sub>
|
||||
</div>
|
||||
|
||||
452
backend/BOOTSTRAP-DATA.md
Normal file
452
backend/BOOTSTRAP-DATA.md
Normal file
@ -0,0 +1,452 @@
|
||||
# Bootstrap 预注入数据说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
Bootstrap 功能在应用启动时自动预注入初始数据,帮助快速搭建开发/测试环境。
|
||||
|
||||
**配置文件**: `config/bootstrap.json`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 预注入数据内容
|
||||
|
||||
### 1️⃣ 用户 (Users)
|
||||
|
||||
预注入 **1 个管理员账户**:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| **username** | `admin` | 管理员用户名 |
|
||||
| **password** | `admin123` | 初始密码(⚠️ 生产环境请修改) |
|
||||
| **email** | `admin@example.com` | 邮箱地址 |
|
||||
|
||||
**用途**:
|
||||
- 登录后台管理系统
|
||||
- 测试用户认证功能
|
||||
- 管理集群和 Registry
|
||||
|
||||
**密码加密**: 使用 bcrypt 加密存储
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Registry (OCI 镜像仓库)
|
||||
|
||||
预注入 **1 个 Harbor Registry**:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| **name** | `Harbor Production` | Registry 名称 |
|
||||
| **url** | `https://harbor.example.com` | Registry 地址 |
|
||||
| **description** | `Production Harbor Registry` | 描述 |
|
||||
| **username** | `admin` | Registry 用户名 |
|
||||
| **password** | `Harbor12345` | Registry 密码(加密存储) |
|
||||
| **insecure** | `false` | 是否跳过 SSL 验证 |
|
||||
|
||||
**用途**:
|
||||
- 浏览 Helm Chart 制品
|
||||
- 拉取 OCI Artifacts
|
||||
- 测试 Registry 连接
|
||||
|
||||
**密码加密**: 使用 AES 加密存储(基于 `ENCRYPTION_KEY` 环境变量)
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Kubernetes 集群 (Clusters)
|
||||
|
||||
预注入 **1 个测试集群**:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| **name** | `Test Cluster` | 集群名称 |
|
||||
| **host** | `https://kubernetes.example.com:6443` | Kubernetes API Server 地址 |
|
||||
| **description** | `Test Kubernetes Cluster` | 描述 |
|
||||
| **caData** | `LS0tLS1CRUdJTi1D...` | CA 证书(Base64 编码) |
|
||||
| **certData** | `LS0tLS1CRUdJTi1D...` | 客户端证书(Base64 编码) |
|
||||
| **keyData** | `LS0tLS1CRUdJTi1S...` | 客户端密钥(Base64 编码) |
|
||||
|
||||
**用途**:
|
||||
- 部署 Helm Chart 应用
|
||||
- 查看集群状态和资源
|
||||
- 测试 Kubernetes 集成
|
||||
|
||||
**证书加密**: CA/Cert/Key 使用 AES 加密存储
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Bootstrap 模式
|
||||
|
||||
### Mock 模式 (run-0)
|
||||
- ✅ Bootstrap 启用
|
||||
- ✅ 数据存储在内存中
|
||||
- ✅ 重启后数据重置
|
||||
|
||||
### 真实模式 (run-1, run-2)
|
||||
- ✅ Bootstrap 启用
|
||||
- ✅ 数据存储在 PostgreSQL
|
||||
- ✅ 重启后数据持久化
|
||||
- ⚠️ **避免重复**: 如果数据已存在会跳过创建
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置文件
|
||||
|
||||
### 完整配置示例 (`config/bootstrap.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
],
|
||||
"registries": [
|
||||
{
|
||||
"name": "Harbor Production",
|
||||
"url": "https://harbor.example.com",
|
||||
"description": "Production Harbor Registry",
|
||||
"username": "admin",
|
||||
"password": "Harbor12345",
|
||||
"insecure": false
|
||||
}
|
||||
],
|
||||
"clusters": [
|
||||
{
|
||||
"name": "Test Cluster",
|
||||
"host": "https://kubernetes.example.com:6443",
|
||||
"description": "Test Kubernetes Cluster",
|
||||
"caData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
|
||||
"certData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
|
||||
"keyData": "LS0tLS1CRUdJTi1SU0EgUFJJVkFURSBLRVktLS0tLQ=="
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 自定义配置
|
||||
|
||||
### 方式 1: 修改配置文件
|
||||
|
||||
```bash
|
||||
# 编辑配置
|
||||
vim config/bootstrap.json
|
||||
|
||||
# 重启应用
|
||||
make run-1
|
||||
```
|
||||
|
||||
### 方式 2: 通过环境变量
|
||||
|
||||
```bash
|
||||
export BOOTSTRAP_CONFIG_JSON='{
|
||||
"enabled": true,
|
||||
"users": [
|
||||
{"username": "myuser", "password": "mypass", "email": "user@example.com"}
|
||||
],
|
||||
"registries": [],
|
||||
"clusters": []
|
||||
}'
|
||||
|
||||
make run-1
|
||||
```
|
||||
|
||||
### 方式 3: 指定配置文件路径
|
||||
|
||||
```bash
|
||||
export BOOTSTRAP_CONFIG_FILE=/path/to/custom-bootstrap.json
|
||||
make run-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全建议
|
||||
|
||||
### ⚠️ 生产环境注意事项
|
||||
|
||||
1. **修改默认密码**
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "YourStrongPasswordHere" // ⚠️ 修改
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **设置强加密密钥**
|
||||
```bash
|
||||
# 生成 32 字节随机密钥
|
||||
export ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
3. **使用真实证书**
|
||||
- 替换 `caData`, `certData`, `keyData` 为真实集群证书
|
||||
- 确保证书有效期和权限正确
|
||||
|
||||
4. **禁用 Bootstrap(可选)**
|
||||
```json
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
5. **删除配置文件**
|
||||
```bash
|
||||
# 首次启动后删除(数据已导入)
|
||||
rm config/bootstrap.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试验证
|
||||
|
||||
### 验证用户
|
||||
|
||||
```bash
|
||||
# 登录测试
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}'
|
||||
|
||||
# 预期返回: {"token": "eyJhbGc..."}
|
||||
```
|
||||
|
||||
### 验证 Registry
|
||||
|
||||
```bash
|
||||
# 查看 Registry 列表
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
|
||||
# 预期返回:
|
||||
# [
|
||||
# {
|
||||
# "id": "...",
|
||||
# "name": "Harbor Production",
|
||||
# "url": "https://harbor.example.com"
|
||||
# }
|
||||
# ]
|
||||
```
|
||||
|
||||
### 验证 Cluster
|
||||
|
||||
```bash
|
||||
# 查看集群列表
|
||||
curl http://localhost:8080/api/v1/clusters
|
||||
|
||||
# 预期返回:
|
||||
# [
|
||||
# {
|
||||
# "id": "...",
|
||||
# "name": "Test Cluster",
|
||||
# "host": "https://kubernetes.example.com:6443"
|
||||
# }
|
||||
# ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 启动日志示例
|
||||
|
||||
### 成功的 Bootstrap 日志
|
||||
|
||||
```
|
||||
🌱 Starting bootstrap seeding...
|
||||
↳ Seeding 1 user(s)...
|
||||
✓ User 'admin' created
|
||||
↳ Seeding 1 registry(ies)...
|
||||
✓ Registry 'Harbor Production' created (credentials encrypted)
|
||||
↳ Seeding 1 cluster(s)...
|
||||
✓ Cluster 'Test Cluster' created (credentials encrypted)
|
||||
✅ Bootstrap seeding completed
|
||||
```
|
||||
|
||||
### 数据已存在的日志
|
||||
|
||||
```
|
||||
🌱 Starting bootstrap seeding...
|
||||
↳ Seeding 1 user(s)...
|
||||
⊙ User 'admin' already exists, skipping
|
||||
↳ Seeding 1 registry(ies)...
|
||||
⊙ Registry 'Harbor Production' already exists, skipping
|
||||
↳ Seeding 1 cluster(s)...
|
||||
⊙ Cluster 'Test Cluster' already exists, skipping
|
||||
✅ Bootstrap seeding completed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重置数据
|
||||
|
||||
### 重置 Mock 模式数据
|
||||
|
||||
```bash
|
||||
# Mock 数据存储在内存,重启即重置
|
||||
make run-0
|
||||
# Ctrl+C
|
||||
make run-0
|
||||
```
|
||||
|
||||
### 重置真实数据库数据
|
||||
|
||||
```bash
|
||||
# 清理并重新创建
|
||||
make clean-1
|
||||
make run-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 更多示例
|
||||
|
||||
### 添加多个用户
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
{
|
||||
"username": "developer",
|
||||
"password": "dev123",
|
||||
"email": "dev@example.com"
|
||||
},
|
||||
{
|
||||
"username": "operator",
|
||||
"password": "ops123",
|
||||
"email": "ops@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 添加多个 Registry
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": [
|
||||
{
|
||||
"name": "Harbor Production",
|
||||
"url": "https://harbor.example.com",
|
||||
"username": "admin",
|
||||
"password": "password1"
|
||||
},
|
||||
{
|
||||
"name": "Docker Hub",
|
||||
"url": "https://registry-1.docker.io",
|
||||
"username": "myuser",
|
||||
"password": "password2"
|
||||
},
|
||||
{
|
||||
"name": "GitHub Container Registry",
|
||||
"url": "https://ghcr.io",
|
||||
"username": "github-user",
|
||||
"password": "ghp_token"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 添加多个集群
|
||||
|
||||
```json
|
||||
{
|
||||
"clusters": [
|
||||
{
|
||||
"name": "Dev Cluster",
|
||||
"host": "https://dev-k8s.example.com:6443",
|
||||
"description": "Development Environment",
|
||||
"caData": "...",
|
||||
"certData": "...",
|
||||
"keyData": "..."
|
||||
},
|
||||
{
|
||||
"name": "Staging Cluster",
|
||||
"host": "https://staging-k8s.example.com:6443",
|
||||
"description": "Staging Environment",
|
||||
"caData": "...",
|
||||
"certData": "...",
|
||||
"keyData": "..."
|
||||
},
|
||||
{
|
||||
"name": "Production Cluster",
|
||||
"host": "https://prod-k8s.example.com:6443",
|
||||
"description": "Production Environment",
|
||||
"caData": "...",
|
||||
"certData": "...",
|
||||
"keyData": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 故障排查
|
||||
|
||||
### 问题 1: Bootstrap 不生效
|
||||
|
||||
**症状**: 启动后没有预注入数据
|
||||
|
||||
**检查**:
|
||||
```bash
|
||||
# 1. 检查配置文件是否存在
|
||||
ls -la config/bootstrap.json
|
||||
|
||||
# 2. 检查 enabled 是否为 true
|
||||
cat config/bootstrap.json | jq .enabled
|
||||
|
||||
# 3. 查看启动日志
|
||||
# 应该看到 "Starting bootstrap seeding..."
|
||||
```
|
||||
|
||||
### 问题 2: 密码不正确
|
||||
|
||||
**症状**: 无法使用预注入的用户登录
|
||||
|
||||
**原因**: 密码在配置文件中可能已修改
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 查看配置文件中的密码
|
||||
cat config/bootstrap.json | jq '.users[0].password'
|
||||
|
||||
# 使用正确的密码测试
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-d '{"username":"admin","password":"配置文件中的密码"}'
|
||||
```
|
||||
|
||||
### 问题 3: 重复创建报错
|
||||
|
||||
**症状**: 日志显示 "duplicate key" 错误
|
||||
|
||||
**原因**: 数据库中已存在同名记录
|
||||
|
||||
**解决**:
|
||||
- 正常现象,Bootstrap 会自动跳过
|
||||
- 如需重新创建,使用 `make clean-1` 清理数据库
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **配置示例**: `config/bootstrap.example.json`
|
||||
- **代码实现**: `internal/bootstrap/seeder.go`
|
||||
- **架构文档**: `docs/architecture.md` - Bootstrap 预注入章节
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-11-10
|
||||
|
||||
324
backend/CODE_FIRST_GUIDE.md
Normal file
324
backend/CODE_FIRST_GUIDE.md
Normal file
@ -0,0 +1,324 @@
|
||||
# Code First API 开发指南
|
||||
|
||||
## 🎯 概述
|
||||
|
||||
本项目现已支持 **Code First** 方式开发 API:
|
||||
1. **后端**:使用 Swagger 注释从代码生成 OpenAPI 文档
|
||||
2. **前端**:使用 Orval 从 OpenAPI 文档生成 TypeScript 客户端
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 工具链
|
||||
|
||||
### 后端
|
||||
- **swaggo/swag**: 从 Go 代码注释生成 OpenAPI/Swagger 文档
|
||||
- 文档:https://github.com/swaggo/swag
|
||||
|
||||
### 前端
|
||||
- **Orval**: 从 OpenAPI 规范生成 TypeScript API 客户端
|
||||
- 文档:https://orval.dev/
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发流程
|
||||
|
||||
### 1. 后端:添加 Swagger 注释
|
||||
|
||||
#### 主程序注释 (cmd/api/main.go)
|
||||
```go
|
||||
// @title OCDP Backend API
|
||||
// @version 1.0
|
||||
// @description OCDP (Open Cloud Development Platform) Backend API
|
||||
//
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
package main
|
||||
```
|
||||
|
||||
#### Handler 方法注释示例
|
||||
```go
|
||||
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||
// @Summary 列出 Repository 中的所有 Artifacts
|
||||
// @Description 列出指定 Repository 中的所有 Artifact,支持按类型过滤
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||
// @Param media_type query string false "过滤类型 (all, chart, image, other)" default(all)
|
||||
// @Success 200 {array} dto.TagResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts [get]
|
||||
func (h *ArtifactHandler) ListArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 生成 OpenAPI 文档
|
||||
|
||||
```bash
|
||||
# 在 backend 目录下运行
|
||||
cd backend
|
||||
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||
|
||||
# 生成的文件在 docs/swagger/ 目录:
|
||||
# - swagger.json
|
||||
# - swagger.yaml
|
||||
# - docs.go
|
||||
```
|
||||
|
||||
### 3. 前端:重新生成 API 客户端
|
||||
|
||||
```bash
|
||||
# 在 frontend 目录下运行
|
||||
cd frontend
|
||||
npm run openapi-gen
|
||||
|
||||
# 或者
|
||||
orval
|
||||
```
|
||||
|
||||
生成的文件在 `src/api/generated-orval/` 目录。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 完整工作流
|
||||
|
||||
### 添加新 API 的步骤
|
||||
|
||||
#### 1. 后端开发
|
||||
```bash
|
||||
# 1.1 创建 DTO
|
||||
# internal/adapter/input/http/dto/new_feature_dto.go
|
||||
|
||||
# 1.2 创建 Handler 并添加 Swagger 注释
|
||||
# internal/adapter/input/http/rest/new_feature_handler.go
|
||||
|
||||
# 1.3 注册路由
|
||||
# cmd/api/main.go
|
||||
|
||||
# 1.4 生成 OpenAPI 文档
|
||||
cd backend
|
||||
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||
|
||||
# 1.5 复制到主文档 (可选)
|
||||
cp docs/swagger/swagger.yaml docs/openapi.yaml
|
||||
```
|
||||
|
||||
#### 2. 前端开发
|
||||
```bash
|
||||
# 2.1 重新生成 API 客户端
|
||||
cd frontend
|
||||
npm run openapi-gen
|
||||
|
||||
# 2.2 使用生成的客户端
|
||||
import { listArtifacts } from '@/api/generated-orval/api';
|
||||
|
||||
const data = await listArtifacts({
|
||||
registryId: 'xxx',
|
||||
repositoryName: 'charts/nginx'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Swagger 注释速查
|
||||
|
||||
### 常用标签
|
||||
|
||||
| 标签 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `@Summary` | 简短描述 | `@Summary 列出所有用户` |
|
||||
| `@Description` | 详细描述 | `@Description 返回系统中的所有用户列表` |
|
||||
| `@Tags` | API 分组 | `@Tags Users` |
|
||||
| `@Accept` | 接受的格式 | `@Accept json` |
|
||||
| `@Produce` | 返回的格式 | `@Produce json` |
|
||||
| `@Param` | 参数 | `@Param id path string true "User ID"` |
|
||||
| `@Success` | 成功响应 | `@Success 200 {object} dto.UserResponse` |
|
||||
| `@Failure` | 失败响应 | `@Failure 404 {object} dto.ErrorResponse` |
|
||||
| `@Router` | 路由定义 | `@Router /users/{id} [get]` |
|
||||
| `@Security` | 安全认证 | `@Security BearerAuth` |
|
||||
|
||||
### 参数类型
|
||||
|
||||
```go
|
||||
// 路径参数 (path)
|
||||
@Param id path string true "User ID"
|
||||
|
||||
// 查询参数 (query)
|
||||
@Param page query int false "Page number" default(1)
|
||||
@Param size query int false "Page size" default(20)
|
||||
|
||||
// 请求体 (body)
|
||||
@Param request body dto.CreateUserRequest true "User data"
|
||||
|
||||
// Header 参数 (header)
|
||||
@Param X-Request-ID header string false "Request ID"
|
||||
```
|
||||
|
||||
### 响应定义
|
||||
|
||||
```go
|
||||
// 单个对象
|
||||
@Success 200 {object} dto.UserResponse
|
||||
|
||||
// 数组
|
||||
@Success 200 {array} dto.UserResponse
|
||||
|
||||
// 自定义响应
|
||||
@Success 200 {object} map[string]interface{}
|
||||
|
||||
// 多个可能的响应
|
||||
@Success 200 {object} dto.UserResponse "Success"
|
||||
@Success 201 {object} dto.UserResponse "Created"
|
||||
@Failure 400 {object} dto.ErrorResponse "Bad Request"
|
||||
@Failure 404 {object} dto.ErrorResponse "Not Found"
|
||||
@Failure 500 {object} dto.ErrorResponse "Internal Error"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 命名规范
|
||||
|
||||
| 层级 | 风格 | 示例 |
|
||||
|------|------|------|
|
||||
| **路径变量** | snake_case | `{registry_id}`, `{repository_name}` |
|
||||
| **查询参数** | snake_case | `?media_type=chart` |
|
||||
| **JSON 字段** | camelCase | `repositoryName`, `mediaType` |
|
||||
| **Go 结构体** | PascalCase | `RepositoryName`, `MediaType` |
|
||||
|
||||
### 2. DTO 定义示例
|
||||
|
||||
```go
|
||||
// ArtifactResponse Artifact 响应
|
||||
type ArtifactResponse struct {
|
||||
RepositoryName string `json:"repositoryName"` // Go: PascalCase, JSON: camelCase
|
||||
Tag string `json:"tag"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 完整的 Handler 示例
|
||||
|
||||
```go
|
||||
// GetArtifact 获取 artifact 详情
|
||||
// @Summary 获取 Artifact 详情
|
||||
// @Description 获取指定 Artifact 的详细信息
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||
// @Success 200 {object} dto.ArtifactResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse "Artifact not found"
|
||||
// @Failure 500 {object} dto.ErrorResponse "Internal server error"
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference} [get]
|
||||
func (h *ArtifactHandler) GetArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
repositoryName := vars["repository_name"]
|
||||
reference := vars["reference"]
|
||||
|
||||
artifact, err := h.artifactService.GetArtifact(r.Context(), registryID, repositoryName, reference)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Artifact not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.ArtifactResponse{
|
||||
RepositoryName: artifact.Repository,
|
||||
Tag: artifact.Tag,
|
||||
Digest: artifact.Digest,
|
||||
Type: string(artifact.Type),
|
||||
Size: artifact.Size,
|
||||
CreatedAt: artifact.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 问题 1: swag 命令未找到
|
||||
```bash
|
||||
# 解决方案:安装 swag
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
# 确保 $GOPATH/bin 在 PATH 中
|
||||
export PATH=$PATH:$(go env GOPATH)/bin
|
||||
```
|
||||
|
||||
### 问题 2: 生成的文档不完整
|
||||
```bash
|
||||
# 确保使用了正确的参数
|
||||
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||
|
||||
# 检查注释格式是否正确
|
||||
# 注释必须紧邻函数定义,中间不能有空行
|
||||
```
|
||||
|
||||
### 问题 3: 前端客户端生成失败
|
||||
```bash
|
||||
# 检查 OpenAPI 文档是否有效
|
||||
cd frontend
|
||||
npx @apidevtools/swagger-cli validate ../backend/docs/openapi.yaml
|
||||
|
||||
# 清理并重新生成
|
||||
rm -rf src/api/generated-orval
|
||||
npm run openapi-gen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资源
|
||||
|
||||
- **Swaggo 文档**: https://github.com/swaggo/swag
|
||||
- **Swaggo 声明式注释**: https://github.com/swaggo/swag#declarative-comments-format
|
||||
- **Orval 文档**: https://orval.dev/
|
||||
- **OpenAPI 规范**: https://swagger.io/specification/
|
||||
|
||||
---
|
||||
|
||||
## ✅ 当前状态
|
||||
|
||||
### 已完成
|
||||
- [x] 安装 swag 工具
|
||||
- [x] 添加主程序 Swagger 注释
|
||||
- [x] 为 Artifact Handler 添加完整注释
|
||||
- [x] 生成 OpenAPI 文档
|
||||
- [x] 前端配置 Orval
|
||||
- [x] 重新生成前端客户端
|
||||
|
||||
### 待完成 (可选)
|
||||
- [ ] 为其他 Handler 添加 Swagger 注释
|
||||
- [ ] Auth Handler
|
||||
- [ ] Cluster Handler
|
||||
- [ ] Registry Handler
|
||||
- [ ] Instance Handler
|
||||
- [ ] Monitoring Handler
|
||||
- [ ] 添加更详细的错误响应定义
|
||||
- [ ] 添加请求/响应示例
|
||||
- [ ] 配置 CI/CD 自动生成文档
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在项目支持 Code First 开发流程:
|
||||
1. **后端开发者**:写代码 + 注释 → 生成 OpenAPI
|
||||
2. **前端开发者**:使用 OpenAPI → 生成 TypeScript 客户端
|
||||
3. **文档自动同步**:代码即文档,保证一致性
|
||||
|
||||
这确保了 API 文档始终与代码保持同步!✨
|
||||
223
backend/DEVELOPMENT.md
Normal file
223
backend/DEVELOPMENT.md
Normal file
@ -0,0 +1,223 @@
|
||||
# 开发环境快速指南
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
- Go 1.21+
|
||||
- Docker & Docker Compose
|
||||
- Air(热加载工具): `go install github.com/cosmtrek/air@latest`
|
||||
|
||||
## 🎯 设计理念
|
||||
|
||||
本项目使用 **Docker Compose Profile** 机制,通过单一的 `docker-compose.yml` 文件支持三种运行模式,避免配置文件重复。
|
||||
|
||||
## 🚀 三种运行模式
|
||||
|
||||
### 模式 0: Mock 模式(最快)
|
||||
|
||||
纯本地运行,无需任何外部依赖,所有服务都是 Mock。
|
||||
|
||||
```bash
|
||||
make run-0
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- ✅ 启动最快(秒启动)
|
||||
- ✅ 无需 Docker
|
||||
- ✅ 支持热加载
|
||||
- ✅ 适合快速功能开发
|
||||
|
||||
**停止:** `Ctrl+C`
|
||||
|
||||
---
|
||||
|
||||
### 模式 1: 开发模式(推荐)
|
||||
|
||||
Docker 提供真实的 PostgreSQL,后端在本地运行并支持热加载。
|
||||
|
||||
```bash
|
||||
make run-1
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- ✅ 真实的数据库
|
||||
- ✅ 支持热加载
|
||||
- ✅ 代码修改实时生效
|
||||
- ✅ 适合日常开发
|
||||
|
||||
**停止:** `Ctrl+C` 停止后端(数据库容器继续运行)
|
||||
|
||||
**清理:** `make clean-1` 清理数据库和临时文件
|
||||
|
||||
---
|
||||
|
||||
### 模式 2: 生产模式
|
||||
|
||||
所有服务完全容器化,模拟生产环境。
|
||||
|
||||
```bash
|
||||
make run-2
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- ✅ 完全容器化
|
||||
- ✅ 接近生产环境
|
||||
- ✅ 后台运行
|
||||
- ❌ 无热加载
|
||||
|
||||
**查看日志:**
|
||||
```bash
|
||||
docker compose --profile backend logs -f
|
||||
```
|
||||
|
||||
**停止:**
|
||||
```bash
|
||||
docker compose --profile backend down
|
||||
```
|
||||
|
||||
**清理:** `make clean-2` 清理所有容器和构建产物
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理命令
|
||||
|
||||
```bash
|
||||
# 清理模式 1 的产物(依赖容器 + 临时文件)
|
||||
make clean-1
|
||||
|
||||
# 清理模式 2 的产物(所有容器 + 构建产物)
|
||||
make clean-2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 常用工作流
|
||||
|
||||
### 日常开发
|
||||
|
||||
```bash
|
||||
# 启动开发环境
|
||||
make run-1
|
||||
|
||||
# ... 编码、测试 ...
|
||||
# 代码修改会自动重新编译
|
||||
|
||||
# 下班停止(Ctrl+C)
|
||||
# 第二天继续(依赖容器还在运行,直接启动)
|
||||
make run-1
|
||||
```
|
||||
|
||||
### 重置数据库
|
||||
|
||||
```bash
|
||||
# 清理并重新开始
|
||||
make clean-1
|
||||
make run-1
|
||||
```
|
||||
|
||||
### 测试生产部署
|
||||
|
||||
```bash
|
||||
# 启动生产模式
|
||||
make run-2
|
||||
|
||||
# 查看日志
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 测试完成后清理
|
||||
make clean-2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境变量配置
|
||||
|
||||
复制并修改环境变量配置:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
主要配置项:
|
||||
|
||||
- `ADAPTER_MODE`: 设置为 `mock` 启用 Mock 模式(模式 0)
|
||||
- `POSTGRES_*`: 数据库配置(模式 1、2)
|
||||
- `JWT_SECRET`: JWT 密钥(生产环境必须修改)
|
||||
- `ENCRYPTION_KEY`: 加密密钥(必须 32 字节)
|
||||
|
||||
---
|
||||
|
||||
## 📂 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `Makefile` | 开发命令入口(5个核心命令) |
|
||||
| `.air.toml` | 热加载配置 |
|
||||
| `docker-compose.yml` | 统一配置文件(使用 profile 区分模式) |
|
||||
| `Dockerfile` | 生产镜像构建配置 |
|
||||
| `DEVELOPMENT.md` | 本文档 |
|
||||
|
||||
### Docker Compose Profile 说明
|
||||
|
||||
- **无 profile**: 只启动 postgres(用于模式 1)
|
||||
- **--profile backend**: 启动 postgres + backend(用于模式 2)
|
||||
- **--profile mock**: 启动 backend-mock(独立的 mock 模式容器)
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 模式 1 启动失败,提示端口被占用?
|
||||
|
||||
A: 检查是否有其他 PostgreSQL 实例在运行:
|
||||
```bash
|
||||
docker ps | grep postgres
|
||||
# 或者修改 .env 中的 POSTGRES_PORT
|
||||
```
|
||||
|
||||
### Q: 如何查看依赖服务状态?
|
||||
|
||||
A:
|
||||
```bash
|
||||
docker compose ps # 模式 1(只有 postgres)
|
||||
docker compose --profile backend ps # 模式 2(postgres + backend)
|
||||
```
|
||||
|
||||
### Q: 模式 1 和模式 2 的数据库数据会互相影响吗?
|
||||
|
||||
A: 会共享同一个数据库容器和数据卷 `ocdp-postgres-data`。
|
||||
- 模式 1: 使用本地 air 连接到 postgres 容器
|
||||
- 模式 2: 使用 backend 容器连接到 postgres 容器
|
||||
- 数据是持久化的,切换模式不会丢失数据
|
||||
|
||||
### Q: 如何完全清理所有 Docker 资源?
|
||||
|
||||
A:
|
||||
```bash
|
||||
make clean-1
|
||||
make clean-2
|
||||
docker system prune -a --volumes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 提示
|
||||
|
||||
- **首次使用**: 推荐从 `make run-0` 开始,快速验证环境
|
||||
- **日常开发**: 使用 `make run-1`,享受热加载和真实数据库
|
||||
- **部署前测试**: 使用 `make run-2`,确保容器化部署没问题
|
||||
- **数据持久化**: 模式 1 和 2 的数据库数据都会持久化,`Ctrl+C` 不会丢失数据
|
||||
- **完全重置**: 使用 `clean-*` 命令会删除数据卷,数据会丢失
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速参考
|
||||
|
||||
```bash
|
||||
make # 显示帮助
|
||||
make run-0 # Mock 模式
|
||||
make run-1 # 开发模式
|
||||
make run-2 # 生产模式
|
||||
make clean-1 # 清理开发环境
|
||||
make clean-2 # 清理生产环境
|
||||
```
|
||||
|
||||
@ -4,12 +4,17 @@
|
||||
# ==================================================
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
ARG GOSUMDB=sum.golang.google.cn
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
ENV GOSUMDB=${GOSUMDB}
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN sh -c 'for i in 1 2 3; do go mod download && exit 0; echo "go mod download failed, retrying ($i/3)" >&2; sleep 5; done; go mod download'
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go
|
||||
|
||||
133
backend/QUICK-REFERENCE.md
Normal file
133
backend/QUICK-REFERENCE.md
Normal file
@ -0,0 +1,133 @@
|
||||
# 快速参考卡片
|
||||
|
||||
## 🚀 五个核心命令
|
||||
|
||||
```bash
|
||||
make run-0 # Mock 模式(秒启动,无需 Docker)
|
||||
make run-1 # 开发模式(Docker 依赖 + 本地热加载)
|
||||
make run-2 # 生产模式(全部容器化)
|
||||
make clean-1 # 清理开发环境
|
||||
make clean-2 # 清理生产环境
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 模式对比
|
||||
|
||||
| 特性 | run-0 | run-1 | run-2 |
|
||||
|------|-------|-------|-------|
|
||||
| **后端位置** | 本地 | 本地 | 容器 |
|
||||
| **数据库** | Mock | 容器 | 容器 |
|
||||
| **热加载** | ✅ | ✅ | ❌ |
|
||||
| **启动速度** | ⚡ 秒启 | 🔥 5秒 | 🐳 30秒 |
|
||||
| **适用场景** | 快速开发 | 日常开发 | 部署测试 |
|
||||
| **依赖 Docker** | ❌ | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 快速功能开发
|
||||
```bash
|
||||
make run-0
|
||||
# 修改代码,自动重新编译
|
||||
# Ctrl+C 停止
|
||||
```
|
||||
|
||||
### 场景 2: 日常开发(推荐)
|
||||
```bash
|
||||
make run-1
|
||||
# 修改代码,自动重新编译
|
||||
# Ctrl+C 停止后端(数据库继续运行)
|
||||
|
||||
# 第二天继续
|
||||
make run-1 # 数据库还在,直接启动
|
||||
|
||||
# 重置数据库
|
||||
make clean-1
|
||||
make run-1
|
||||
```
|
||||
|
||||
### 场景 3: 部署前测试
|
||||
```bash
|
||||
make run-2
|
||||
# 查看日志
|
||||
docker compose --profile backend logs -f
|
||||
|
||||
# 测试完成
|
||||
make clean-2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Docker Compose 直接使用
|
||||
|
||||
### 启动依赖(run-1 等价)
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
### 启动完整环境(run-2 等价)
|
||||
```bash
|
||||
docker compose --profile backend up -d
|
||||
```
|
||||
|
||||
### 查看状态
|
||||
```bash
|
||||
docker compose ps # run-1
|
||||
docker compose --profile backend ps # run-2
|
||||
```
|
||||
|
||||
### 停止
|
||||
```bash
|
||||
docker compose down # run-1
|
||||
docker compose --profile backend down # run-2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 关键文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `Makefile` | 5个核心命令 |
|
||||
| `docker-compose.yml` | 使用 profile 的统一配置 |
|
||||
| `.air.toml` | 热加载配置 |
|
||||
| `env.example` | 环境变量模板 |
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Compose Profile 原理
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
postgres:
|
||||
# 默认启动(无需 profile)
|
||||
|
||||
backend:
|
||||
profiles: [backend] # 需要 --profile backend
|
||||
```
|
||||
|
||||
**使用方式**:
|
||||
```bash
|
||||
docker compose up -d # 只启动 postgres
|
||||
docker compose --profile backend up -d # 启动 postgres + backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 快速提示
|
||||
|
||||
- 首次使用运行: `go install github.com/cosmtrek/air@latest`
|
||||
- 查看帮助: `make` 或 `make help`
|
||||
- 完整文档: 查看 `DEVELOPMENT.md`
|
||||
- 审查报告: 查看 `REVIEW.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 一句话总结
|
||||
|
||||
**三种模式,五个命令,一个 Docker Compose 文件。**
|
||||
|
||||
@ -1,89 +1,343 @@
|
||||
# OCDP Backend
|
||||
|
||||
Go 后端服务,提供 Kubernetes Helm Chart 部署能力。
|
||||
基于 Go 的 Kubernetes Helm Chart 管理服务后端,提供完整的制品浏览和应用部署能力。
|
||||
|
||||
## 技术栈
|
||||
## ✨ 特性
|
||||
|
||||
- Go 1.21+
|
||||
- gorilla/mux (HTTP 路由)
|
||||
- ORAS Go SDK v2 (OCI 操作)
|
||||
- Helm SDK (Helm 操作)
|
||||
- Kubernetes client-go
|
||||
- PostgreSQL
|
||||
- 🎪 **Helm Chart 管理** - 完整的 Helm 生命周期支持
|
||||
- 📦 **多 Registry 支持** - Harbor、Docker Hub、GHCR 等
|
||||
- 🔍 **Artifact 浏览** - 自动识别类型、大小、metadata
|
||||
- 🚀 **OCI 标准兼容** - 使用 ORAS Go SDK v2
|
||||
- 🖥️ **多集群支持** - 管理多个 Kubernetes 集群
|
||||
- 🔐 **认证支持** - JWT + 密码加密
|
||||
- 📊 **实时状态** - Helm Release 状态监控
|
||||
- 🏗️ **六边形架构** - 清晰的分层设计,易于测试和扩展
|
||||
- 🔄 **双模式支持** - Mock 模式(开发调试)+ 默认模式(真实 PostgreSQL)
|
||||
|
||||
## 启动
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式 1: Mock 模式(最快)
|
||||
|
||||
适合快速功能开发和 API 测试,无需数据库。
|
||||
|
||||
```bash
|
||||
# Mock 模式(无需数据库,无需外部服务)
|
||||
ADAPTER_MODE=mock go run cmd/api/main.go
|
||||
# 安装 Air(首次)
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
# 生产模式(需要 PostgreSQL + K8s/Harbor 连接验证)
|
||||
# 启动 PostgreSQL
|
||||
docker compose up -d postgres
|
||||
|
||||
# 启动后端(生产模式)
|
||||
cd backend
|
||||
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable"
|
||||
export JWT_SECRET="your-jwt-secret"
|
||||
export ENCRYPTION_KEY="your-32-byte-encryption-key"
|
||||
export PORT=8081
|
||||
export ADAPTER_MODE=production
|
||||
export KUBECONFIG=/home/ivanwu/.kube/config # 或你的 kubeconfig 路径
|
||||
|
||||
# Harbor 凭证(可选,用于验证 Registry 连接)
|
||||
export HARBOR_URL="https://harbor.bwgdi.com"
|
||||
export HARBOR_USERNAME="your-harbor-user"
|
||||
export HARBOR_PASSWORD="your-harbor-password"
|
||||
|
||||
# NFS 配置(可选)
|
||||
export NFS_SERVER="10.6.80.11"
|
||||
export NFS_SHARE="/volume1/NFS"
|
||||
|
||||
go run cmd/api/main.go
|
||||
# 启动 Mock 模式
|
||||
make dev-mock
|
||||
```
|
||||
|
||||
## 环境变量说明
|
||||
### 方式 2: 本地 Backend + Docker 数据库(推荐日常开发)
|
||||
|
||||
| 变量 | 必需 | 说明 |
|
||||
支持数据持久化和热重载。
|
||||
|
||||
```bash
|
||||
# 启动数据库
|
||||
docker compose up -d postgres
|
||||
|
||||
# 启动 Backend
|
||||
make dev
|
||||
```
|
||||
|
||||
### 方式 3: 完全容器化(生产部署)
|
||||
|
||||
适合生产环境和 CI/CD。
|
||||
|
||||
```bash
|
||||
# 启动完整服务
|
||||
make prod
|
||||
|
||||
# 或
|
||||
docker compose --profile backend up -d
|
||||
```
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
# Mock 模式(快速测试)
|
||||
make run-mock
|
||||
|
||||
# 或
|
||||
export ADAPTER_MODE=mock
|
||||
go run cmd/api/main.go
|
||||
|
||||
# Production 模式(需要数据库)
|
||||
make run-prod
|
||||
```
|
||||
|
||||
### 验证服务
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 查看 API
|
||||
curl http://localhost:8080/api/v1/registries | jq
|
||||
|
||||
# 访问 Swagger UI (交互式 API 文档)
|
||||
open http://localhost:8080/api/docs
|
||||
```
|
||||
|
||||
## 📚 文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [快速开始](QUICK-START.md) | **快速开始指南** - 3分钟上手 ⭐ |
|
||||
| [命令速查表](COMMANDS.md) | **Make 命令参考** - 所有命令详解 ⭐ |
|
||||
| [部署指南](DEPLOYMENT-GUIDE.md) | **完整部署指南** - Mock 和默认模式 |
|
||||
| [架构文档](docs/architecture.md) | 六边形架构、目录结构、开发指南 |
|
||||
| [OpenAPI 规范](docs/openapi.yaml) | **OpenAPI 3.0 规范** - 标准 API 定义 |
|
||||
| [Swagger UI](http://localhost:8080/api/docs) | **交互式 API 文档** - 在线测试 API 🚀 |
|
||||
| [API 与测试](docs/api-and-test.md) | REST API 参考文档 + 测试指南 |
|
||||
|
||||
### 🎯 API 文档使用指南
|
||||
|
||||
**方式 1: Swagger UI (推荐)** ⭐
|
||||
|
||||
启动服务后访问:[http://localhost:8080/api/docs](http://localhost:8080/api/docs)
|
||||
|
||||
特性:
|
||||
- 📖 交互式文档 - 所有 API 可视化展示
|
||||
- 🔧 在线测试 - 直接在浏览器中测试 API
|
||||
- 🔐 认证支持 - 支持 JWT Token 认证
|
||||
- 📦 Schema 查看 - 查看所有请求/响应模型
|
||||
|
||||
**方式 2: OpenAPI 规范文件**
|
||||
|
||||
```bash
|
||||
# 查看规范文件
|
||||
cat docs/openapi.yaml
|
||||
|
||||
# 使用 OpenAPI 工具生成客户端
|
||||
openapi-generator-cli generate -i docs/openapi.yaml -g go -o ./client
|
||||
|
||||
# 在线验证
|
||||
curl http://localhost:8080/api/docs/openapi.yaml
|
||||
```
|
||||
|
||||
**方式 3: Markdown 文档**
|
||||
|
||||
查看 [docs/api-and-test.md](docs/api-and-test.md) - 完整的 API 参考文档
|
||||
|
||||
## 🏗️ 架构概览
|
||||
|
||||
采用**六边形架构**(Hexagonal Architecture),实现清晰的分层和依赖倒置:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Input Adapters │
|
||||
│ (HTTP REST API) │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Domain Layer │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Entities │ │ Services │ │ Interfaces │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Output Adapters │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Database │ │ OCI Client │ │ Helm Client │ │
|
||||
│ │ (Mock/Prod) │ │ (Mock/ORAS) │ │ (Mock/Real) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
详见 [架构文档](docs/architecture.md)
|
||||
|
||||
## 🎯 核心 API
|
||||
|
||||
| 分类 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| DATABASE_URL | 是 | PostgreSQL 连接字符串 |
|
||||
| JWT_SECRET | 是 | JWT 签名密钥 |
|
||||
| ENCRYPTION_KEY | 是 | 32位加密密钥 |
|
||||
| PORT | 否 | 服务端口,默认 8080 |
|
||||
| ADAPTER_MODE | 否 | `mock` 或 `production`,默认 production |
|
||||
| KUBECONFIG | 生产模式 | Kubernetes kubeconfig 文件路径 |
|
||||
| HARBOR_URL | 否 | Harbor URL,用于 Registry 验证 |
|
||||
| HARBOR_USERNAME | 否 | Harbor 用户名 |
|
||||
| HARBOR_PASSWORD | 否 | Harbor 密码 |
|
||||
| **认证** | `POST /api/v1/auth/login` | 用户登录 |
|
||||
| **集群** | `GET /api/v1/clusters` | 列出集群 |
|
||||
| | `POST /api/v1/clusters` | 创建集群 |
|
||||
| **Registry** | `GET /api/v1/registries` | 列出 Registry |
|
||||
| | `POST /api/v1/registries` | 创建 Registry |
|
||||
| **Artifact** | `GET /api/v1/registries/{id}/repositories` | 列出仓库 |
|
||||
| | `GET /api/v1/registries/{id}/repositories/{repo}/artifacts` | 列出制品 |
|
||||
| **实例** | `POST /api/v1/clusters/{id}/instances` | 安装应用 |
|
||||
| | `GET /api/v1/clusters/{id}/instances` | 列出实例 |
|
||||
| | `PUT /api/v1/clusters/{id}/instances/{instanceId}` | 升级应用 |
|
||||
| | `DELETE /api/v1/clusters/{id}/instances/{instanceId}` | 卸载应用 |
|
||||
| | `GET /api/v1/clusters/{id}/instances/{instanceId}/entries` | 查看实例入口 |
|
||||
| **监控** | `GET /api/v1/monitoring/summary` | 监控摘要 |
|
||||
|
||||
## 连接验证
|
||||
完整 API 文档: [docs/api.md](docs/api.md)
|
||||
|
||||
在生产模式下,创建 Cluster 或 Registry 时会自动验证连接:
|
||||
## 🔧 开发
|
||||
|
||||
- **Cluster**: 尝试使用提供的凭证连接 K8s API Server
|
||||
- **Registry**: 尝试使用提供的凭证登录 Harbor
|
||||
### 环境要求
|
||||
|
||||
如果验证失败,创建会返回错误信息。
|
||||
- Go 1.21+
|
||||
- PostgreSQL 15+ (生产模式)
|
||||
- Docker & Docker Compose (可选)
|
||||
|
||||
## API 访问
|
||||
### 常用命令
|
||||
|
||||
- API: http://localhost:8081/api/v1
|
||||
- Health: http://localhost:8081/health
|
||||
- Swagger: http://localhost:8081/api/docs
|
||||
```bash
|
||||
# 查看所有命令
|
||||
make help
|
||||
|
||||
## 项目结构
|
||||
# 开发
|
||||
make dev # 开发模式(热重载)
|
||||
make build # 构建
|
||||
make run-mock # Mock 模式运行
|
||||
make run-prod # Production 模式运行
|
||||
|
||||
# Docker Compose
|
||||
make mock # Mock 模式
|
||||
make prod # 生产模式
|
||||
make logs # 查看日志
|
||||
make status # 查看状态
|
||||
make stop # 停止服务
|
||||
|
||||
# 数据库
|
||||
make db-up # 启动数据库
|
||||
make db-psql # 连接数据库
|
||||
make db-backup # 备份数据库
|
||||
make pgadmin # 启动 pgAdmin
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/api/ # 入口
|
||||
├── cmd/api/ # 程序入口
|
||||
├── internal/
|
||||
│ ├── domain/ # 领域层
|
||||
│ │ ├── entity/ # 实体
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ └── repository/ # 接口
|
||||
│ ├── domain/ # 🎯 领域层(核心)
|
||||
│ │ ├── entity/ # 实体
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ └── repository/ # 接口定义
|
||||
│ ├── adapter/
|
||||
│ │ ├── input/http/ # REST API
|
||||
│ │ └── output/ # 数据库、OCI、Helm
|
||||
│ └── bootstrap/ # 启动配置
|
||||
└── docs/ # OpenAPI 规范
|
||||
```
|
||||
│ │ ├── input/http/ # 📥 REST API
|
||||
│ │ └── output/ # 📤 数据库、OCI、Helm
|
||||
│ ├── bootstrap/ # Bootstrap 预注入
|
||||
│ └── pkg/ # 🔧 工具包
|
||||
├── docs/ # 📚 文档
|
||||
├── config/ # ⚙️ 配置
|
||||
└── scripts/ # 🛠️ 脚本
|
||||
```
|
||||
|
||||
详见 [架构文档](docs/architecture.md)
|
||||
|
||||
## 🔐 安全配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 必需配置
|
||||
ADAPTER_MODE=production
|
||||
JWT_SECRET=your-jwt-secret
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/ocdp
|
||||
|
||||
# 生成安全密钥
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### Bootstrap 预注入
|
||||
|
||||
在 `config/bootstrap.json` 中配置初始数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"users": [
|
||||
{"username": "admin", "password": "admin123", "email": "admin@example.com"}
|
||||
],
|
||||
"registries": [
|
||||
{"name": "harbor", "url": "https://harbor.example.com", "username": "admin", "password": "secret"}
|
||||
],
|
||||
"clusters": [
|
||||
{"name": "prod", "host": "https://k8s.example.com:6443", "caData": "...", "certData": "...", "keyData": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详见 [架构文档 - Bootstrap 预注入](docs/architecture.md#bootstrap-预注入)
|
||||
|
||||
## 🌐 服务访问
|
||||
|
||||
| 服务 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| Backend API | http://localhost:8080/api/v1 | REST API |
|
||||
| Health Check | http://localhost:8080/health | 健康检查 |
|
||||
| PostgreSQL | localhost:5432 | 数据库 |
|
||||
| pgAdmin | http://localhost:5050 | 数据库管理 |
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**端口被占用**:
|
||||
```bash
|
||||
# 修改 .env 中的 BACKEND_PORT
|
||||
BACKEND_PORT=8081
|
||||
```
|
||||
|
||||
**数据库连接失败**:
|
||||
```bash
|
||||
# 检查数据库状态
|
||||
docker compose ps postgres
|
||||
docker compose logs postgres
|
||||
```
|
||||
|
||||
**完全重置**:
|
||||
```bash
|
||||
# 停止并删除所有数据
|
||||
docker compose down -v
|
||||
docker compose --profile production up -d
|
||||
```
|
||||
|
||||
更多问题参见 [部署文档 - 故障排查](docs/deployment.md#故障排查)
|
||||
|
||||
## 📊 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **语言** | Go 1.21+ |
|
||||
| **Web 框架** | gorilla/mux |
|
||||
| **OCI 客户端** | ORAS Go SDK v2 |
|
||||
| **Helm 集成** | Helm SDK |
|
||||
| **Kubernetes** | client-go |
|
||||
| **数据库** | PostgreSQL 15+ |
|
||||
| **容器化** | Docker, Docker Compose |
|
||||
| **热重载** | Air |
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
### 规范和文档
|
||||
- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
|
||||
- [OCI Image Specification](https://github.com/opencontainers/image-spec)
|
||||
- [Helm Documentation](https://helm.sh/docs/)
|
||||
|
||||
### 使用的库
|
||||
- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP 路由
|
||||
- [ORAS Go SDK](https://oras.land/docs/category/go-library) - OCI Registry 操作
|
||||
- [Helm SDK](https://helm.sh/docs/topics/advanced/) - Helm 操作
|
||||
- [Kubernetes Client-Go](https://github.com/kubernetes/client-go) - K8s API 客户端
|
||||
|
||||
## 📝 待办事项
|
||||
|
||||
- [ ] 添加单元测试和集成测试
|
||||
- [ ] 实现 Rate Limiting
|
||||
- [ ] 添加审计日志
|
||||
- [ ] 实现 Webhook 通知
|
||||
- [ ] 支持更多 OCI Registry 类型
|
||||
- [ ] 添加 Metrics 和 Tracing
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
**Version**: 2.2.0
|
||||
**Last Updated**: 2025-11-09
|
||||
**Port**: 8080 (default)
|
||||
|
||||
283
backend/REVIEW.md
Normal file
283
backend/REVIEW.md
Normal file
@ -0,0 +1,283 @@
|
||||
# 项目实现审查报告
|
||||
|
||||
## ✅ 要求对照检查
|
||||
|
||||
### 1. 文档结构 ✅
|
||||
|
||||
#### 1.1 README.md ✅
|
||||
- 位置: `/backend/README.md`
|
||||
- 状态: ✅ 已存在
|
||||
- 内容: 项目概述、快速开始、API文档、架构概览
|
||||
|
||||
#### 1.2 docs/ ✅
|
||||
所有文档齐全:
|
||||
|
||||
##### 2.1 architecture.md ✅
|
||||
- 位置: `/backend/docs/architecture.md`
|
||||
- 状态: ✅ 已存在
|
||||
- 内容完整包含:
|
||||
- ✅ 2.1.1 **需求描述** (Requirement Description)
|
||||
- 项目背景
|
||||
- 核心需求
|
||||
- 功能需求
|
||||
- 非功能需求
|
||||
- ✅ 2.1.2 **业务建模** (Business Modeling)
|
||||
- 业务领域
|
||||
- 核心实体
|
||||
- 业务流程
|
||||
- 用例场景
|
||||
- ✅ 2.1.3 **技术建模** (Technical Modeling)
|
||||
- ✅ 2.1.3.1 **六边形架构** (Hexagonal Architecture)
|
||||
- 架构图
|
||||
- 层次说明
|
||||
- 依赖倒置原则
|
||||
- ✅ 2.1.3.2 **技术选型** (Technology Selection)
|
||||
- Go 语言
|
||||
- Web 框架
|
||||
- OCI/Helm 客户端
|
||||
- 数据库选型
|
||||
|
||||
##### 2.2 api-and-test.md ✅
|
||||
- 位置: `/backend/docs/api-and-test.md`
|
||||
- 状态: ✅ 已存在
|
||||
- 内容: REST API 文档、测试指南
|
||||
|
||||
##### 2.3 deployment.md ✅
|
||||
- 位置: `/backend/docs/deployment.md`
|
||||
- 状态: ✅ 已存在
|
||||
- 内容: 部署指南、环境配置
|
||||
|
||||
---
|
||||
|
||||
### 2. 开发模式 ✅
|
||||
|
||||
#### 3.1 Mode 0: Hot Reload + Mock ✅
|
||||
```bash
|
||||
make run-0
|
||||
```
|
||||
- ✅ 热加载支持 (Air)
|
||||
- ✅ Mock 所有依赖 (内存实现)
|
||||
- ✅ 无需任何容器
|
||||
- ✅ 使用环境变量 `ADAPTER_MODE=mock`
|
||||
|
||||
**实现方式**:
|
||||
```makefile
|
||||
run-0:
|
||||
ADAPTER_MODE=mock air -c .air.toml
|
||||
```
|
||||
|
||||
#### 3.2 Mode 1: Hot Reload + Real Deps in Container ✅
|
||||
```bash
|
||||
make run-1
|
||||
```
|
||||
- ✅ 热加载支持 (Air)
|
||||
- ✅ 真实依赖 (PostgreSQL) 运行在容器中
|
||||
- ✅ 后端代码在本地运行
|
||||
- ✅ 使用 Docker Compose **无 profile** 启动 postgres
|
||||
|
||||
**实现方式**:
|
||||
```makefile
|
||||
run-1:
|
||||
@docker compose up -d postgres
|
||||
@sleep 5
|
||||
ADAPTER_MODE= \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
|
||||
air -c .air.toml
|
||||
```
|
||||
|
||||
#### 3.3 Mode 2: All in Container ✅
|
||||
```bash
|
||||
make run-2
|
||||
```
|
||||
- ✅ 所有服务容器化
|
||||
- ✅ 无热加载(生产模式)
|
||||
- ✅ 使用 Docker Compose **--profile backend**
|
||||
|
||||
**实现方式**:
|
||||
```makefile
|
||||
run-2:
|
||||
@docker compose --profile backend up --build -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Makefile 设计 ✅
|
||||
|
||||
#### 核心要求: 使用 Profile 共享单一 Docker Compose ✅
|
||||
|
||||
**✅ 已实现**: 使用 `docker-compose.yml` + Profile 机制
|
||||
|
||||
| 命令 | 功能 | Docker Compose 用法 |
|
||||
|------|------|---------------------|
|
||||
| `make run-0` | Mock 模式 | 不使用 Docker |
|
||||
| `make run-1` | 开发模式 | `docker compose up -d postgres` (无 profile) |
|
||||
| `make run-2` | 生产模式 | `docker compose --profile backend up -d` |
|
||||
| `make clean-1` | 清理 run-1 | `docker compose down -v` |
|
||||
| `make clean-2` | 清理 run-2 | `docker compose --profile backend down -v` |
|
||||
|
||||
#### Docker Compose Profile 配置 ✅
|
||||
|
||||
文件: `/backend/docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
# 默认启动(无需 profile)
|
||||
# 用于 run-1
|
||||
|
||||
backend:
|
||||
profiles:
|
||||
- backend # 需要 --profile backend 才启动
|
||||
# 用于 run-2
|
||||
|
||||
backend-mock:
|
||||
profiles:
|
||||
- mock # 可选的 mock 容器模式
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 单一配置文件,避免重复
|
||||
- ✅ Profile 清晰区分不同模式
|
||||
- ✅ 符合 Docker Compose 最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 📊 完整流程对照
|
||||
|
||||
### Process 1: Product Description ✅
|
||||
|
||||
#### 1.1 Requirement ✅
|
||||
- 文档: `docs/architecture.md` - 2.1.1 需求描述
|
||||
- 内容: 项目背景、核心需求、功能需求、非功能需求
|
||||
|
||||
#### 1.2 What API I Need ✅
|
||||
- 文档: `docs/api-and-test.md`
|
||||
- 内容: REST API 端点定义、请求/响应示例
|
||||
|
||||
#### 1.3 Generator Docs ✅
|
||||
所有文档已完整生成:
|
||||
- ✅ `docs/architecture.md` - 架构文档
|
||||
- ✅ Business Modeling - 业务建模
|
||||
- ✅ Technical Modeling - 技术建模
|
||||
- ✅ Hexagonal Architecture - 六边形架构
|
||||
- ✅ Technology Selection - 技术选型
|
||||
- ✅ `docs/api-and-test.md` - API 文档
|
||||
- ✅ `docs/deployment.md` - 部署文档
|
||||
|
||||
---
|
||||
|
||||
### Process 2: Development ✅
|
||||
|
||||
#### 2.1 Develop Mode 1, 2, 3 ✅
|
||||
- ✅ Mode 0 (Mock): `make run-0`
|
||||
- ✅ Mode 1 (Dev): `make run-1`
|
||||
- ✅ Mode 2 (Prod): `make run-2`
|
||||
|
||||
---
|
||||
|
||||
### Process 3: Provide Makefile ✅
|
||||
|
||||
#### 3.1 Five Commands ✅
|
||||
```bash
|
||||
make run-0 # ✅ Hot reload + Mock (memory)
|
||||
make run-1 # ✅ Hot reload + Real deps (container)
|
||||
make run-2 # ✅ All in container
|
||||
make clean-1 # ✅ Clean run-1 artifacts
|
||||
make clean-2 # ✅ Clean run-2 artifacts
|
||||
```
|
||||
|
||||
#### 3.2 Using Profile to Share Docker Compose ✅
|
||||
- ✅ 使用单一的 `docker-compose.yml`
|
||||
- ✅ 通过 `--profile backend` 区分 mode 1 和 mode 2
|
||||
- ✅ Mode 1: `docker compose up -d postgres` (无 profile)
|
||||
- ✅ Mode 2: `docker compose --profile backend up -d`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心设计亮点
|
||||
|
||||
### 1. Docker Compose Profile 机制 ⭐⭐⭐⭐⭐
|
||||
|
||||
**传统做法** (❌ 不推荐):
|
||||
```
|
||||
docker-compose.dev.yml # 重复配置 postgres
|
||||
docker-compose.prod.yml # 重复配置 postgres
|
||||
```
|
||||
|
||||
**本项目做法** (✅ 推荐):
|
||||
```yaml
|
||||
# docker-compose.yml (单一文件)
|
||||
services:
|
||||
postgres: # 默认启动,run-1 和 run-2 共享
|
||||
backend:
|
||||
profiles: [backend] # 只在 run-2 时启动
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 配置不重复 (DRY 原则)
|
||||
- ✅ 维护简单 (只需修改一个文件)
|
||||
- ✅ 数据共享 (run-1 和 run-2 使用同一个数据库)
|
||||
|
||||
### 2. 三种模式的清晰定位
|
||||
|
||||
| 模式 | 场景 | 启动速度 | 真实依赖 | 热加载 |
|
||||
|------|------|---------|---------|--------|
|
||||
| Mode 0 | 快速开发、单元测试 | ⚡ 秒启动 | ❌ Mock | ✅ |
|
||||
| Mode 1 | 日常开发、集成测试 | 🔥 5秒 | ✅ | ✅ |
|
||||
| Mode 2 | 部署前测试、生产环境 | 🐳 30秒 | ✅ | ❌ |
|
||||
|
||||
### 3. 命令语义清晰
|
||||
|
||||
```bash
|
||||
run-0 → 0 依赖(Mock all)
|
||||
run-1 → 1 部分容器化(Deps in container)
|
||||
run-2 → 2 完全容器化(All in container)
|
||||
|
||||
clean-1 → 清理 run-1 的产物
|
||||
clean-2 → 清理 run-2 的产物
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 最终验证
|
||||
|
||||
### 文档完整性
|
||||
- [x] README.md
|
||||
- [x] docs/architecture.md
|
||||
- [x] 2.1.1 需求描述
|
||||
- [x] 2.1.2 业务建模
|
||||
- [x] 2.1.3 技术建模
|
||||
- [x] 2.1.3.1 六边形架构
|
||||
- [x] 2.1.3.2 技术选型
|
||||
- [x] docs/api-and-test.md
|
||||
- [x] docs/deployment.md
|
||||
|
||||
### 开发模式
|
||||
- [x] Mode 0: Hot reload + Mock (memory)
|
||||
- [x] Mode 1: Hot reload + Real deps (container)
|
||||
- [x] Mode 2: All in container
|
||||
|
||||
### Makefile
|
||||
- [x] 5 个核心命令
|
||||
- [x] 使用 Profile 共享 Docker Compose
|
||||
- [x] run-1 和 run-2 使用同一个 docker-compose.yml
|
||||
|
||||
---
|
||||
|
||||
## 🎉 结论
|
||||
|
||||
**所有要求均已满足!✅**
|
||||
|
||||
项目完全符合你的设计要求:
|
||||
1. ✅ 完整的文档结构 (README + docs/)
|
||||
2. ✅ 三种开发模式 (0/1/2)
|
||||
3. ✅ 五个核心命令 (run-0/1/2, clean-1/2)
|
||||
4. ✅ 使用 Profile 共享 Docker Compose(关键设计)
|
||||
|
||||
**特别亮点**:
|
||||
- 使用 Docker Compose Profile 避免配置重复
|
||||
- 命名清晰,语义明确 (run-0/1/2)
|
||||
- 产物隔离 (clean-1 vs clean-2)
|
||||
|
||||
**可以直接使用!** 🚀
|
||||
|
||||
400
backend/TEST-REPORT.md
Normal file
400
backend/TEST-REPORT.md
Normal file
@ -0,0 +1,400 @@
|
||||
# 测试报告
|
||||
|
||||
**测试日期**: 2025-11-10
|
||||
**测试内容**: Makefile 五个核心命令的功能测试
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试结果总览
|
||||
|
||||
| 命令 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `make run-0` | ✅ 通过 | Mock 模式正常工作 |
|
||||
| `make run-1` | ✅ 通过 | Docker 依赖 + 热加载正常 |
|
||||
| `make run-2` | ✅ 通过 | 完全容器化部署成功 |
|
||||
| `make clean-1` | ✅ 通过 | 清理 run-1 产物完整 |
|
||||
| `make clean-2` | ✅ 通过 | 清理 run-2 产物完整 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 详细测试结果
|
||||
|
||||
### 1️⃣ make run-0 (Mock 模式)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
make run-0
|
||||
```
|
||||
|
||||
**预期行为**:
|
||||
- ✅ 使用 Mock 适配器(内存存储)
|
||||
- ✅ 无需任何 Docker 容器
|
||||
- ✅ 支持热加载(Air)
|
||||
- ✅ 环境变量 `ADAPTER_MODE=mock`
|
||||
|
||||
**实际结果**:
|
||||
```
|
||||
✅ 服务启动成功
|
||||
✅ Health API 正常: {"status":"healthy"}
|
||||
✅ Registries API 返回 Mock 数据: 1 条记录
|
||||
✅ Clusters API 返回 Mock 数据: 1 条记录
|
||||
✅ Bootstrap 数据预注入成功
|
||||
```
|
||||
|
||||
**日志输出**:
|
||||
```
|
||||
📝 Configuration: mode=mock, port=8080
|
||||
✅ Output Adapters initialized (mode: mock)
|
||||
🌱 Starting bootstrap seeding...
|
||||
✓ User 'admin' created
|
||||
✓ Registry 'Harbor Production' created
|
||||
✓ Cluster 'Test Cluster' created
|
||||
✅ Bootstrap seeding completed
|
||||
🌐 Server starting on :8080
|
||||
```
|
||||
|
||||
**结论**: ✅ **完全符合预期**
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ make run-1 (Docker 依赖 + 热加载)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
make run-1
|
||||
```
|
||||
|
||||
**预期行为**:
|
||||
- ✅ 启动 PostgreSQL 容器(使用 `docker compose up -d postgres`)
|
||||
- ✅ 后端代码在本地运行
|
||||
- ✅ 支持热加载(Air)
|
||||
- ✅ 连接真实数据库
|
||||
- ✅ 环境变量 `ADAPTER_MODE=` (空,表示真实模式)
|
||||
|
||||
**实际结果**:
|
||||
```
|
||||
✅ PostgreSQL 容器启动: ocdp-postgres (healthy)
|
||||
✅ 后端连接数据库成功
|
||||
✅ 数据库 Schema 初始化成功
|
||||
✅ Health API 正常
|
||||
✅ Registries API 连接真实 PostgreSQL
|
||||
✅ Clusters API 连接真实 PostgreSQL
|
||||
```
|
||||
|
||||
**Docker 容器状态**:
|
||||
```
|
||||
NAME IMAGE STATUS
|
||||
ocdp-postgres postgres:17-alpine Up (healthy)
|
||||
```
|
||||
|
||||
**配置**:
|
||||
```makefile
|
||||
run-1:
|
||||
@docker compose up -d postgres
|
||||
ADAPTER_MODE= \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
|
||||
air -c .air.toml
|
||||
```
|
||||
|
||||
**结论**: ✅ **完全符合预期**
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ make run-2 (完全容器化)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
make run-2
|
||||
```
|
||||
|
||||
**预期行为**:
|
||||
- ✅ 构建后端 Docker 镜像
|
||||
- ✅ 启动 PostgreSQL + Backend 容器
|
||||
- ✅ 使用 `--profile backend` 区分模式
|
||||
- ✅ 后台运行
|
||||
- ✅ 无热加载
|
||||
|
||||
**实际结果**:
|
||||
```
|
||||
✅ Docker 镜像构建成功: backend-backend
|
||||
✅ 两个容器启动: ocdp-postgres + ocdp-backend
|
||||
✅ 容器健康检查通过
|
||||
✅ Health API 正常
|
||||
✅ Registries API 返回真实数据: 1 条记录
|
||||
✅ Clusters API 返回真实数据: 1 条记录
|
||||
✅ 后端日志正常输出
|
||||
```
|
||||
|
||||
**Docker 容器状态**:
|
||||
```
|
||||
NAME IMAGE STATUS
|
||||
ocdp-backend backend-backend Up (healthy)
|
||||
ocdp-postgres postgres:17-alpine Up (healthy)
|
||||
```
|
||||
|
||||
**配置**:
|
||||
```makefile
|
||||
run-2:
|
||||
@docker compose --profile backend up --build -d
|
||||
```
|
||||
|
||||
**Docker Compose Profile 验证**:
|
||||
- ✅ 使用单一的 `docker-compose.yml`
|
||||
- ✅ PostgreSQL 默认启动(无 profile)
|
||||
- ✅ Backend 需要 `--profile backend` 才启动
|
||||
- ✅ 符合设计要求
|
||||
|
||||
**结论**: ✅ **完全符合预期**
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ make clean-1 (清理 run-1 产物)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
make clean-1
|
||||
```
|
||||
|
||||
**预期行为**:
|
||||
- ✅ 停止并删除 PostgreSQL 容器
|
||||
- ✅ 删除 Docker 数据卷
|
||||
- ✅ 清空 `tmp/` 目录
|
||||
- ✅ 删除 Docker 网络
|
||||
|
||||
**清理前状态**:
|
||||
```
|
||||
Docker 容器: ocdp-postgres (running)
|
||||
tmp/ 目录: 包含 main, build-errors.log, test.txt
|
||||
Docker 卷: ocdp-postgres-data
|
||||
Docker 网络: ocdp-network
|
||||
```
|
||||
|
||||
**清理后状态**:
|
||||
```
|
||||
✅ Docker 容器: 0 个
|
||||
✅ tmp/ 目录: 已清空
|
||||
✅ Docker 卷: ocdp-postgres-data 已删除
|
||||
✅ Docker 网络: ocdp-network 已删除
|
||||
```
|
||||
|
||||
**执行输出**:
|
||||
```
|
||||
🧹 Cleaning run-1 artifacts...
|
||||
Container ocdp-postgres Stopping
|
||||
Container ocdp-postgres Stopped
|
||||
Container ocdp-postgres Removing
|
||||
Container ocdp-postgres Removed
|
||||
Volume ocdp-postgres-data Removing
|
||||
Volume ocdp-postgres-data Removed
|
||||
Network ocdp-network Removed
|
||||
✅ run-1 cleaned
|
||||
```
|
||||
|
||||
**配置**:
|
||||
```makefile
|
||||
clean-1:
|
||||
@docker compose down -v
|
||||
@rm -rf tmp/
|
||||
```
|
||||
|
||||
**结论**: ✅ **清理完整,无残留**
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ make clean-2 (清理 run-2 产物)
|
||||
|
||||
**测试命令**:
|
||||
```bash
|
||||
make clean-2
|
||||
```
|
||||
|
||||
**预期行为**:
|
||||
- ✅ 停止并删除 Backend + PostgreSQL 容器
|
||||
- ✅ 删除 Docker 数据卷
|
||||
- ✅ 清空 `bin/` 和 `dist/` 目录
|
||||
- ✅ 删除 Docker 网络
|
||||
|
||||
**清理前状态**:
|
||||
```
|
||||
Docker 容器: ocdp-backend (running), ocdp-postgres (running)
|
||||
bin/ 目录: 存在
|
||||
dist/ 目录: 包含 test.tar.gz
|
||||
Docker 卷: ocdp-postgres-data
|
||||
Docker 网络: ocdp-network
|
||||
```
|
||||
|
||||
**清理后状态**:
|
||||
```
|
||||
✅ Docker 容器: 0 个
|
||||
✅ bin/ 目录: 0 个文件
|
||||
✅ dist/ 目录: 0 个文件
|
||||
✅ Docker 卷: ocdp-postgres-data 已删除
|
||||
✅ Docker 网络: ocdp-network 已删除
|
||||
```
|
||||
|
||||
**执行输出**:
|
||||
```
|
||||
🧹 Cleaning run-2 artifacts...
|
||||
Container ocdp-backend Stopping
|
||||
Container ocdp-backend Stopped
|
||||
Container ocdp-backend Removing
|
||||
Container ocdp-backend Removed
|
||||
Container ocdp-postgres Stopping
|
||||
Container ocdp-postgres Stopped
|
||||
Container ocdp-postgres Removing
|
||||
Container ocdp-postgres Removed
|
||||
Volume ocdp-postgres-data Removed
|
||||
Network ocdp-network Removed
|
||||
✅ run-2 cleaned
|
||||
```
|
||||
|
||||
**配置**:
|
||||
```makefile
|
||||
clean-2:
|
||||
@docker compose --profile backend down -v
|
||||
@rm -rf bin/ dist/
|
||||
```
|
||||
|
||||
**结论**: ✅ **清理完整,无残留**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心设计验证
|
||||
|
||||
### Docker Compose Profile 机制 ✅
|
||||
|
||||
**验证内容**: 使用单一 `docker-compose.yml` + Profile 实现三种模式
|
||||
|
||||
**验证结果**:
|
||||
|
||||
1. **run-1**: `docker compose up -d postgres`
|
||||
- ✅ 只启动 postgres 服务(无 profile)
|
||||
- ✅ backend 服务不启动(需要 profile)
|
||||
|
||||
2. **run-2**: `docker compose --profile backend up -d`
|
||||
- ✅ 启动 postgres + backend 两个服务
|
||||
- ✅ 使用 `--profile backend` 激活 backend
|
||||
|
||||
3. **共享配置**:
|
||||
- ✅ 两种模式使用同一个 `docker-compose.yml`
|
||||
- ✅ postgres 配置不重复
|
||||
- ✅ 符合 DRY 原则
|
||||
|
||||
**配置文件**:
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
# 默认启动(无需 profile)
|
||||
|
||||
backend:
|
||||
profiles:
|
||||
- backend # 需要 --profile backend
|
||||
depends_on:
|
||||
- postgres
|
||||
```
|
||||
|
||||
**结论**: ✅ **设计正确,实现完美**
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 功能测试
|
||||
|
||||
### Health Check API
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
**结果**: ✅ 三种模式均正常
|
||||
```json
|
||||
{"status":"healthy"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Registries API
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/registries
|
||||
```
|
||||
|
||||
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
|
||||
**run-1 (PostgreSQL)**: ✅ 返回真实数据
|
||||
**run-2 (容器)**: ✅ 返回真实数据
|
||||
|
||||
---
|
||||
|
||||
### Clusters API
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/clusters
|
||||
```
|
||||
|
||||
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
|
||||
**run-1 (PostgreSQL)**: ✅ 返回真实数据
|
||||
**run-2 (容器)**: ✅ 返回真实数据
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### ✅ 所有测试通过
|
||||
|
||||
- ✅ **run-0**: Mock 模式工作正常
|
||||
- ✅ **run-1**: Docker 依赖 + 热加载正常
|
||||
- ✅ **run-2**: 完全容器化部署成功
|
||||
- ✅ **clean-1**: 清理 run-1 产物完整
|
||||
- ✅ **clean-2**: 清理 run-2 产物完整
|
||||
|
||||
### ✅ 核心设计验证
|
||||
|
||||
- ✅ **Docker Compose Profile** 机制正确
|
||||
- ✅ **单一配置文件** 实现多模式
|
||||
- ✅ **产物隔离** 清晰
|
||||
- ✅ **命名语义** 明确
|
||||
|
||||
### 🎯 符合所有设计要求
|
||||
|
||||
1. ✅ 三种运行模式(0/1/2)
|
||||
2. ✅ 五个核心命令
|
||||
3. ✅ 使用 Profile 共享 Docker Compose
|
||||
4. ✅ 清理命令分离且完整
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用建议
|
||||
|
||||
### 日常开发流程
|
||||
|
||||
```bash
|
||||
# 快速开发
|
||||
make run-0
|
||||
|
||||
# 需要真实数据库
|
||||
make run-1
|
||||
|
||||
# 测试部署
|
||||
make run-2
|
||||
|
||||
# 清理环境
|
||||
make clean-1 # 或 make clean-2
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **run-0** 和 **run-1** 是前台运行,`Ctrl+C` 停止
|
||||
2. **run-2** 是后台运行,使用 `docker compose --profile backend down` 停止
|
||||
3. **clean-1** 和 **clean-2** 会删除数据卷,数据会丢失
|
||||
4. run-1 和 run-2 共享同一个 PostgreSQL 容器配置和数据
|
||||
|
||||
---
|
||||
|
||||
## 📝 测试环境
|
||||
|
||||
- **操作系统**: Linux 5.15.0-160-generic
|
||||
- **Docker**: 已安装
|
||||
- **Docker Compose**: 已安装
|
||||
- **Go**: 1.24+
|
||||
- **Air**: 已安装
|
||||
|
||||
---
|
||||
|
||||
**测试结论**: 🎉 **所有功能正常,可以投入使用!**
|
||||
|
||||
@ -34,8 +34,10 @@ import (
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/rest"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output/k8s"
|
||||
"github.com/ocdp/cluster-service/internal/bootstrap"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/password"
|
||||
@ -73,6 +75,7 @@ func main() {
|
||||
// ===== 5. 创建 Domain Services =====
|
||||
authService := service.NewAuthService(
|
||||
repos.UserRepo,
|
||||
repos.WorkspaceRepo,
|
||||
passwordHasher,
|
||||
tokenGenerator,
|
||||
)
|
||||
@ -98,25 +101,23 @@ func main() {
|
||||
repos.HelmClient,
|
||||
repos.OCIClient,
|
||||
repos.EntryClient,
|
||||
repos.BindingRepo,
|
||||
)
|
||||
instanceService.SetDiagnosticsClient(repos.DiagnosticsClient)
|
||||
instanceService.SetTenantProvisioning(repos.WorkspaceRepo, repos.TenantKubeClient)
|
||||
instanceService.SetScaleClient(k8s.NewScaleClient())
|
||||
|
||||
monitoringService := service.NewMonitoringService(
|
||||
repos.ClusterRepo,
|
||||
repos.MetricsClient,
|
||||
)
|
||||
|
||||
// Workspace Service
|
||||
workspaceService := service.NewWorkspaceService(
|
||||
repos.WorkspaceRepo,
|
||||
repos.QuotaRepo,
|
||||
repos.UserRepo,
|
||||
)
|
||||
|
||||
// User Management Service
|
||||
userManagementService := service.NewUserManagementService(
|
||||
repos.UserRepo,
|
||||
repos.WorkspaceRepo,
|
||||
passwordHasher,
|
||||
repos.BindingRepo,
|
||||
repos.ClusterRepo,
|
||||
repos.TenantKubeClient,
|
||||
repos.AuditRepo,
|
||||
)
|
||||
|
||||
log.Println("✅ Domain Services initialized")
|
||||
@ -125,7 +126,7 @@ func main() {
|
||||
bootstrapConfig, err := bootstrap.LoadBootstrapConfig()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err)
|
||||
// 使用默认配置
|
||||
// 使用安全的空配置,避免在配置错误时写入任何预置账号或集群凭据。
|
||||
bootstrapConfig = bootstrap.GetDefaultBootstrapConfig()
|
||||
}
|
||||
|
||||
@ -141,51 +142,22 @@ func main() {
|
||||
artifactHandler := rest.NewArtifactHandler(artifactService)
|
||||
instanceHandler := rest.NewInstanceHandler(instanceService)
|
||||
monitoringHandler := rest.NewMonitoringHandler(monitoringService)
|
||||
workspaceHandler := rest.NewWorkspaceHandler(workspaceService)
|
||||
swaggerHandler := rest.NewSwaggerHandler()
|
||||
|
||||
// Workspace Handler
|
||||
workspaceHandler := rest.NewWorkspaceHandler(workspaceService, authService)
|
||||
|
||||
// User Management Handler (Admin only)
|
||||
userManagementHandler := rest.NewUserManagementHandler(userManagementService, authService, workspaceService)
|
||||
|
||||
// User Handler
|
||||
userHandler := rest.NewUserHandler(authService, workspaceService)
|
||||
|
||||
// Storage Handler
|
||||
storageService := service.NewStorageService(repos.StorageRepo)
|
||||
storageHandler := rest.NewStorageHandler(storageService)
|
||||
|
||||
// Wire storage service into instance service for layered storage config
|
||||
instanceService.SetStorageService(storageService)
|
||||
|
||||
// Chart Reference Handler
|
||||
chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo)
|
||||
chartRefHandler := rest.NewChartReferenceHandler(chartRefService)
|
||||
|
||||
// Values Template Handler
|
||||
valuesTemplateService := service.NewValuesTemplateService(repos.ValuesTemplateRepo, repos.ChartRefRepo)
|
||||
valuesTemplateHandler := rest.NewValuesTemplateHandler(valuesTemplateService)
|
||||
|
||||
log.Println("✅ Input Adapters (REST handlers) initialized")
|
||||
|
||||
// ===== 8. 设置路由 =====
|
||||
router := setupRouter(
|
||||
authHandler,
|
||||
authService,
|
||||
clusterHandler,
|
||||
registryHandler,
|
||||
artifactHandler,
|
||||
instanceHandler,
|
||||
monitoringHandler,
|
||||
swaggerHandler,
|
||||
workspaceHandler,
|
||||
userManagementHandler,
|
||||
userHandler,
|
||||
storageHandler,
|
||||
chartRefHandler,
|
||||
valuesTemplateHandler,
|
||||
tokenGenerator,
|
||||
config.AllowedOrigins,
|
||||
swaggerHandler,
|
||||
)
|
||||
|
||||
// ===== 9. 启动服务器 =====
|
||||
@ -208,28 +180,21 @@ func main() {
|
||||
|
||||
// Config 应用配置
|
||||
type Config struct {
|
||||
AdapterMode string
|
||||
Port string
|
||||
JWTSecret string
|
||||
EncryptionKey string
|
||||
DatabaseURL string
|
||||
AllowedOrigins []string
|
||||
AdapterMode string
|
||||
Port string
|
||||
JWTSecret string
|
||||
EncryptionKey string
|
||||
DatabaseURL string
|
||||
}
|
||||
|
||||
// loadConfig 加载配置
|
||||
func loadConfig() *Config {
|
||||
allowedOrigins := getEnv("ALLOWED_DEV_ORIGINS", "")
|
||||
var origins []string
|
||||
if allowedOrigins != "" {
|
||||
origins = strings.Split(allowedOrigins, ",")
|
||||
}
|
||||
return &Config{
|
||||
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
AllowedOrigins: origins,
|
||||
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,72 +210,20 @@ func getEnv(key, defaultValue string) string {
|
||||
// setupRouter 设置路由
|
||||
func setupRouter(
|
||||
authHandler *rest.AuthHandler,
|
||||
authService *service.AuthService,
|
||||
clusterHandler *rest.ClusterHandler,
|
||||
registryHandler *rest.RegistryHandler,
|
||||
artifactHandler *rest.ArtifactHandler,
|
||||
instanceHandler *rest.InstanceHandler,
|
||||
monitoringHandler *rest.MonitoringHandler,
|
||||
swaggerHandler *rest.SwaggerHandler,
|
||||
workspaceHandler *rest.WorkspaceHandler,
|
||||
userManagementHandler *rest.UserManagementHandler,
|
||||
userHandler *rest.UserHandler,
|
||||
storageHandler *rest.StorageHandler,
|
||||
chartRefHandler *rest.ChartReferenceHandler,
|
||||
valuesTemplateHandler *rest.ValuesTemplateHandler,
|
||||
tokenGenerator *jwt.JWTManager,
|
||||
allowedOrigins []string,
|
||||
swaggerHandler *rest.SwaggerHandler,
|
||||
) *mux.Router {
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
// 全局中间件
|
||||
router.Use(loggingMiddleware)
|
||||
router.Use(corsMiddleware(allowedOrigins))
|
||||
|
||||
// 预检请求处理 - 必须放在路由注册之前
|
||||
router.HandleFunc("/{path:.*}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodOptions {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// 非 OPTIONS 请求返回 404
|
||||
http.NotFound(w, r)
|
||||
}).Methods(http.MethodOptions)
|
||||
|
||||
// JWT 解析中间件 - 为所有需要认证的请求设置用户信息 header
|
||||
jwtMiddleware := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 跳过认证路由
|
||||
if r.URL.Path == "/api/v1/auth/login" ||
|
||||
r.URL.Path == "/api/v1/auth/register" ||
|
||||
r.URL.Path == "/api/v1/auth/refresh" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
userID, username, role, workspaceID, err := tokenGenerator.Verify(token)
|
||||
if err == nil && userID != "" {
|
||||
// 设置 header 供 handlers 使用
|
||||
r.Header.Set("X-User-ID", userID)
|
||||
r.Header.Set("X-Username", username)
|
||||
r.Header.Set("X-User-Role", role)
|
||||
r.Header.Set("X-Workspace-ID", workspaceID)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
router.Use(corsMiddleware)
|
||||
|
||||
// 健康检查
|
||||
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@ -328,100 +241,68 @@ func setupRouter(
|
||||
|
||||
// API v1
|
||||
api := router.PathPrefix("/api/v1").Subrouter()
|
||||
// 应用 CORS 和 JWT 中间件到所有 API 路由
|
||||
api.Use(corsMiddleware(allowedOrigins))
|
||||
api.Use(jwtMiddleware)
|
||||
|
||||
// ===== 认证路由 =====
|
||||
api.HandleFunc("/auth/register", authHandler.Register)
|
||||
api.HandleFunc("/auth/login", authHandler.Login)
|
||||
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
// ===== 用户账户路由 =====
|
||||
api.HandleFunc("/users/me", userHandler.GetCurrentUser).Methods(http.MethodGet)
|
||||
api.HandleFunc("/users/me/password", userHandler.ChangePassword).Methods(http.MethodPut)
|
||||
api.HandleFunc("/users/me/workspace", userHandler.GetCurrentUserWorkspace).Methods(http.MethodGet)
|
||||
|
||||
// ===== 用户管理路由(Admin) =====
|
||||
api.HandleFunc("/admin/users", userManagementHandler.CreateUser).Methods(http.MethodPost)
|
||||
api.HandleFunc("/admin/users", userManagementHandler.ListUsers).Methods(http.MethodGet)
|
||||
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.GetUser).Methods(http.MethodGet)
|
||||
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.UpdateUser).Methods(http.MethodPut)
|
||||
api.HandleFunc("/admin/users/{user_id}/active", userManagementHandler.SetUserActive).Methods(http.MethodPut)
|
||||
api.HandleFunc("/admin/users/{user_id}/workspace", userManagementHandler.ChangeUserWorkspace).Methods(http.MethodPut)
|
||||
api.HandleFunc("/admin/users/{user_id}/password", userManagementHandler.ResetPassword).Methods(http.MethodPut)
|
||||
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.DeleteUser).Methods(http.MethodDelete)
|
||||
|
||||
// ===== Workspace 路由 =====
|
||||
api.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
|
||||
api.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
|
||||
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.GetWorkspace).Methods(http.MethodGet)
|
||||
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.UpdateWorkspace).Methods(http.MethodPut)
|
||||
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.DeleteWorkspace).Methods(http.MethodDelete)
|
||||
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.GetWorkspaceQuotas).Methods(http.MethodGet)
|
||||
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.SetWorkspaceQuotas).Methods(http.MethodPut)
|
||||
protected := api.PathPrefix("").Subrouter()
|
||||
protected.Use(authMiddleware(authService))
|
||||
protected.HandleFunc("/auth/me", authHandler.Me).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/auth/register", authHandler.Register).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/users", authHandler.ListUsers).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/users", authHandler.Register).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/users/{user_id}", authHandler.UpdateUser).Methods(http.MethodPut)
|
||||
protected.HandleFunc("/users/{user_id}", authHandler.DeleteUser).Methods(http.MethodDelete)
|
||||
|
||||
// ===== 集群路由 =====
|
||||
api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
|
||||
api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
|
||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet)
|
||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
|
||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
|
||||
api.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
|
||||
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
|
||||
|
||||
// ===== Registry 路由 =====
|
||||
api.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
|
||||
api.HandleFunc("/registries", registryHandler.GetAllRegistries).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}", registryHandler.GetRegistry).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}", registryHandler.UpdateRegistry).Methods(http.MethodPut)
|
||||
api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
|
||||
api.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
|
||||
|
||||
// ===== Storage Backend 路由 =====
|
||||
api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost)
|
||||
api.HandleFunc("/storage-backends", storageHandler.GetAllStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/resolve", storageHandler.ResolveStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.GetStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete)
|
||||
|
||||
// ===== Chart Reference 路由 =====
|
||||
api.HandleFunc("/chart-references", chartRefHandler.CreateChartReference).Methods(http.MethodPost)
|
||||
api.HandleFunc("/chart-references", chartRefHandler.GetAllChartReferences).Methods(http.MethodGet)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.GetChartReference).Methods(http.MethodGet)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.UpdateChartReference).Methods(http.MethodPut)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.DeleteChartReference).Methods(http.MethodDelete)
|
||||
|
||||
// ===== Values Template 路由 =====
|
||||
api.HandleFunc("/values-templates", valuesTemplateHandler.CreateValuesTemplate).Methods(http.MethodPost)
|
||||
api.HandleFunc("/values-templates", valuesTemplateHandler.GetAllValuesTemplates).Methods(http.MethodGet)
|
||||
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.GetValuesTemplate).Methods(http.MethodGet)
|
||||
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.UpdateValuesTemplate).Methods(http.MethodPut)
|
||||
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.DeleteValuesTemplate).Methods(http.MethodDelete)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates", valuesTemplateHandler.GetValuesTemplatesByChartReference).Methods(http.MethodGet)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates/history", valuesTemplateHandler.GetValuesTemplateHistory).Methods(http.MethodGet)
|
||||
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates/rollback", valuesTemplateHandler.RollbackValuesTemplate).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/registries", registryHandler.GetAllRegistries).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}", registryHandler.GetRegistry).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}", registryHandler.UpdateRegistry).Methods(http.MethodPut)
|
||||
protected.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
|
||||
protected.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
|
||||
|
||||
// ===== Artifact 路由 =====
|
||||
api.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}", artifactHandler.GetArtifact).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
|
||||
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values", artifactHandler.GetArtifactValues).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}", artifactHandler.GetArtifact).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-yaml", artifactHandler.GetArtifactValuesYAML).Methods(http.MethodGet)
|
||||
|
||||
// ===== Instance 路由 =====
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.ListInstances).Methods(http.MethodGet)
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.GetInstance).Methods(http.MethodGet)
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.UpdateInstance).Methods(http.MethodPut)
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.DeleteInstance).Methods(http.MethodDelete)
|
||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.ListInstances).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.GetInstance).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.UpdateInstance).Methods(http.MethodPut)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.DeleteInstance).Methods(http.MethodDelete)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/diagnostics", instanceHandler.GetInstanceDiagnostics).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/logs/stream", instanceHandler.StreamInstanceLogs).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/scale", instanceHandler.ScaleInstance).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/values-diff", instanceHandler.GetInstanceValuesDiff).Methods(http.MethodGet)
|
||||
|
||||
// ===== Monitoring 路由 =====
|
||||
api.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
|
||||
api.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
|
||||
api.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
|
||||
api.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
|
||||
|
||||
// ===== Workspace 路由 =====
|
||||
protected.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/workspaces/credentials/kubeconfig", workspaceHandler.IssueCurrentKubeconfig).Methods(http.MethodGet)
|
||||
protected.HandleFunc("/workspaces/{workspace_id}/clusters", workspaceHandler.InitClusterBinding).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/workspaces/{workspace_id}/kubeconfig", workspaceHandler.IssueKubeconfig).Methods(http.MethodPost)
|
||||
protected.HandleFunc("/workspaces/{workspace_id}/suspend", workspaceHandler.SuspendWorkspace).Methods(http.MethodPost)
|
||||
|
||||
// 处理 MethodNotAllowed 错误(OPTIONS 请求会触发)
|
||||
router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@ -436,6 +317,35 @@ func setupRouter(
|
||||
return router
|
||||
}
|
||||
|
||||
func authMiddleware(authService *service.AuthService) mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
if token == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||
return
|
||||
}
|
||||
principal, err := authService.VerifyAccessToken(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(authz.WithPrincipal(r.Context(), principal)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":%q,"message":%q}`, code, message)))
|
||||
}
|
||||
|
||||
// loggingMiddleware 日志中间件
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@ -446,54 +356,25 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// corsMiddleware CORS 中间件
|
||||
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置 CORS 头
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
|
||||
// 验证 origin 是否在允许列表中
|
||||
if origin != "" && len(allowedOrigins) > 0 {
|
||||
allowed := false
|
||||
for _, ao := range allowedOrigins {
|
||||
if ao == origin || ao == "*" {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
// Origin 不在允许列表中,拒绝请求
|
||||
w.Header().Set("Access-Control-Allow-Origin", "")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 处理 OPTIONS 预检请求
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有配置 allowedOrigins,默认允许所有
|
||||
if len(allowedOrigins) == 0 {
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
}
|
||||
|
||||
// 优先处理 OPTIONS 预检请求
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 CORS 头
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
fmt.Println(string(hash))
|
||||
}
|
||||
@ -2,9 +2,9 @@
|
||||
"enabled": true,
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "change-me-in-production",
|
||||
"email": "admin@example.com"
|
||||
"username": "bootstrap-admin",
|
||||
"password": "replace-with-a-strong-password",
|
||||
"email": "bootstrap-admin@example.local"
|
||||
}
|
||||
],
|
||||
"registries": [
|
||||
@ -12,8 +12,8 @@
|
||||
"name": "my-harbor",
|
||||
"url": "https://harbor.example.com",
|
||||
"description": "Harbor Registry",
|
||||
"username": "admin",
|
||||
"password": "change-me",
|
||||
"username": "robot$project+ocdp",
|
||||
"password": "replace-with-robot-token",
|
||||
"insecure": false
|
||||
}
|
||||
],
|
||||
@ -28,4 +28,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-15432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
@ -58,47 +58,29 @@ services:
|
||||
build:
|
||||
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||
dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile}
|
||||
args:
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
|
||||
image: ocdp-backend:latest
|
||||
container_name: ocdp-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /media/ivanwu/DATA/ocdp-go/.env
|
||||
- path: ../.env
|
||||
required: false
|
||||
format: raw
|
||||
environment:
|
||||
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||
PORT: 8080
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||
KUBECONFIG: ""
|
||||
ALLOWED_DEV_ORIGINS: ${ALLOWED_DEV_ORIGINS:-*}
|
||||
# Bootstrap data (loaded from .env via env_file above)
|
||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-}
|
||||
BOOTSTRAP_ADMIN_PASS: ${BOOTSTRAP_ADMIN_PASS:-}
|
||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
|
||||
BOOTSTRAP_REGISTRY_NAME: ${BOOTSTRAP_REGISTRY_NAME:-}
|
||||
BOOTSTRAP_REGISTRY_URL: ${BOOTSTRAP_REGISTRY_URL:-}
|
||||
BOOTSTRAP_REGISTRY_DESC: ${BOOTSTRAP_REGISTRY_DESC:-}
|
||||
BOOTSTRAP_REGISTRY_USER: ${BOOTSTRAP_REGISTRY_USER:-}
|
||||
BOOTSTRAP_REGISTRY_PASS: ${BOOTSTRAP_REGISTRY_PASS:-}
|
||||
BOOTSTRAP_REGISTRY_INSECURE: ${BOOTSTRAP_REGISTRY_INSECURE:-}
|
||||
BOOTSTRAP_CLUSTERS: ${BOOTSTRAP_CLUSTERS:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER1_HOST:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER1_DESC:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CA: ${BOOTSTRAP_CLUSTER_CLUSTER1_CA:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER1_CERT:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER1_KEY:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER2_HOST:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER2_DESC:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_CA: ${BOOTSTRAP_CLUSTER_CLUSTER2_CA:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER2_CERT:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER2_KEY:-}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
- "${BACKEND_PORT:-18081}:8080"
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
- ./data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@ -119,6 +101,9 @@ services:
|
||||
build:
|
||||
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||
dockerfile: ${BACKEND_MOCK_BUILD_DOCKERFILE:-Dockerfile.mock}
|
||||
args:
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
|
||||
container_name: ocdp-backend-mock
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@ -127,9 +112,9 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
- "${BACKEND_PORT:-18081}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@ -149,7 +134,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change-me}
|
||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
|
||||
ports:
|
||||
|
||||
1802
backend/docs/api-and-test.md
Normal file
1802
backend/docs/api-and-test.md
Normal file
File diff suppressed because it is too large
Load Diff
1305
backend/docs/architecture.md
Normal file
1305
backend/docs/architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1546
backend/docs/deployment.md
Normal file
1546
backend/docs/deployment.md
Normal file
File diff suppressed because it is too large
Load Diff
3047
backend/docs/docs.go
3047
backend/docs/docs.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -22,7 +22,6 @@ require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/MakeNowJust/heredoc v1.0.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
@ -47,7 +46,6 @@ require (
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@ -95,12 +93,10 @@ require (
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/cobra v1.10.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/swaggo/swag v1.16.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xlab/treeprint v1.2.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
@ -108,7 +104,6 @@ require (
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
|
||||
@ -10,8 +10,6 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
@ -22,8 +20,6 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
|
||||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
@ -98,18 +94,11 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
@ -171,7 +160,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@ -187,9 +175,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
|
||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
@ -228,7 +213,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
@ -296,8 +280,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
|
||||
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||
@ -369,7 +351,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
@ -384,21 +365,16 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
@ -423,19 +399,15 @@ google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3i
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
password := "admin123"
|
||||
|
||||
memory := 64 * 1024
|
||||
iterations := 3
|
||||
parallelism := 2
|
||||
saltLength := 16
|
||||
keyLength := 32
|
||||
|
||||
salt := make([]byte, saltLength)
|
||||
rand.Read(salt)
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(memory), uint8(parallelism), uint32(keyLength))
|
||||
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, memory, iterations, parallelism, b64Salt, b64Hash)
|
||||
|
||||
fmt.Println(encodedHash)
|
||||
}
|
||||
@ -6,9 +6,9 @@ type RepositoryListResponse struct {
|
||||
RegistryURL string `json:"registryUrl"`
|
||||
Repositories []string `json:"repositories"`
|
||||
Total int `json:"total"`
|
||||
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
||||
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
||||
Message string `json:"message,omitempty"` // User-friendly message
|
||||
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
||||
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
||||
Message string `json:"message,omitempty"` // User-friendly message
|
||||
}
|
||||
|
||||
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
|
||||
@ -23,11 +23,11 @@ type ArtifactResponse struct {
|
||||
|
||||
// TagResponse Tag 响应(前端期望的扁平化结构)
|
||||
type TagResponse struct {
|
||||
RepositoryName string `json:"repositoryName"` // Repository name
|
||||
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
||||
Type string `json:"type"` // Artifact type: chart, image, other
|
||||
RepositoryName string `json:"repositoryName"` // Repository name
|
||||
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
||||
Type string `json:"type"` // Artifact type: chart, image, other
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
Size int64 `json:"size"` // Artifact size (bytes)
|
||||
Size int64 `json:"size"` // Artifact size (bytes)
|
||||
}
|
||||
|
||||
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
|
||||
@ -42,8 +42,7 @@ type ValuesSchemaResponse struct {
|
||||
Schema string `json:"schema"`
|
||||
}
|
||||
|
||||
// ValuesResponse Values 响应
|
||||
type ValuesResponse struct {
|
||||
Values string `json:"values"`
|
||||
// ValuesYAMLResponse Helm Chart 默认 values.yaml 响应
|
||||
type ValuesYAMLResponse struct {
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,18 @@ package dto
|
||||
|
||||
// RegisterRequest 用户注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role,omitempty"`
|
||||
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
|
||||
}
|
||||
|
||||
// LoginRequest 用户登录请求
|
||||
@ -19,17 +29,53 @@ type RefreshTokenRequest struct {
|
||||
|
||||
// AuthResponse 认证响应
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
PermissionVersion int `json:"permissionVersion"`
|
||||
}
|
||||
|
||||
// UserResponse 用户信息响应
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||
IsActive bool `json:"isActive"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest 管理员更新用户状态/角色请求
|
||||
type UpdateUserRequest struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
package dto
|
||||
|
||||
// CreateChartReferenceRequest 创建 Chart 引用请求
|
||||
type CreateChartReferenceRequest struct {
|
||||
RegistryID string `json:"registry_id" binding:"required"`
|
||||
Repository string `json:"repository" binding:"required"`
|
||||
ChartName string `json:"chart_name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UpdateChartReferenceRequest 更新 Chart 引用请求
|
||||
type UpdateChartReferenceRequest struct {
|
||||
RegistryID string `json:"registry_id"`
|
||||
Repository string `json:"repository"`
|
||||
ChartName string `json:"chart_name"`
|
||||
Description string `json:"description"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
}
|
||||
|
||||
// ChartReferenceResponse Chart 引用响应
|
||||
type ChartReferenceResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
RegistryID string `json:"registry_id"`
|
||||
Repository string `json:"repository"`
|
||||
ChartName string `json:"chart_name"`
|
||||
Description string `json:"description"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@ -12,9 +12,10 @@ type CreateClusterRequest struct {
|
||||
KeyDataAlt string `json:"key_data"`
|
||||
Token string `json:"token"`
|
||||
Description string `json:"description"`
|
||||
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
|
||||
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
|
||||
IsShared bool `json:"isShared"` // 是否为共享集群
|
||||
Visibility string `json:"visibility"`
|
||||
GlobalShared bool `json:"globalShared"`
|
||||
GlobalSharedAlt bool `json:"global_shared"`
|
||||
DefaultNamespace string `json:"defaultNamespace"`
|
||||
}
|
||||
|
||||
// UpdateClusterRequest 更新集群请求
|
||||
@ -29,9 +30,10 @@ type UpdateClusterRequest struct {
|
||||
KeyDataAlt string `json:"key_data"`
|
||||
Token string `json:"token"`
|
||||
Description string `json:"description"`
|
||||
IsolationMode string `json:"isolationMode"`
|
||||
Visibility string `json:"visibility"`
|
||||
GlobalShared bool `json:"globalShared"`
|
||||
GlobalSharedAlt bool `json:"global_shared"`
|
||||
DefaultNamespace string `json:"defaultNamespace"`
|
||||
IsShared *bool `json:"isShared"`
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
@ -62,16 +64,15 @@ func (r *UpdateClusterRequest) Normalize() {
|
||||
|
||||
// ClusterResponse 集群响应(敏感数据已脱敏)
|
||||
type ClusterResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||
OwnerID string `json:"ownerId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
|
||||
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
|
||||
IsShared bool `json:"isShared"` // 是否为共享集群
|
||||
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Visibility string `json:"visibility"`
|
||||
DefaultNamespace string `json:"defaultNamespace,omitempty"`
|
||||
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
|
||||
HasCAData bool `json:"hasCaData"`
|
||||
HasCertData bool `json:"hasCertData"`
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
@ -10,17 +8,17 @@ import (
|
||||
// ToRegistryResponse 转换 Registry 实体为响应 DTO(脱敏)
|
||||
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
||||
response := &RegistryResponse{
|
||||
ID: registry.ID,
|
||||
WorkspaceID: registry.WorkspaceID,
|
||||
OwnerID: registry.OwnerID,
|
||||
Name: registry.Name,
|
||||
URL: registry.URL,
|
||||
Description: registry.Description,
|
||||
Username: registry.Username,
|
||||
Insecure: registry.Insecure,
|
||||
IsShared: registry.IsShared,
|
||||
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
ID: registry.ID,
|
||||
WorkspaceID: registry.WorkspaceID,
|
||||
OwnerID: registry.OwnerID,
|
||||
Visibility: registry.Visibility,
|
||||
Name: registry.Name,
|
||||
URL: registry.URL,
|
||||
Description: registry.Description,
|
||||
Username: registry.Username,
|
||||
Insecure: registry.Insecure,
|
||||
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
// 脱敏处理密码
|
||||
@ -38,12 +36,11 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
||||
ID: cluster.ID,
|
||||
WorkspaceID: cluster.WorkspaceID,
|
||||
OwnerID: cluster.OwnerID,
|
||||
Visibility: cluster.Visibility,
|
||||
Name: cluster.Name,
|
||||
Host: cluster.Host,
|
||||
Description: cluster.Description,
|
||||
IsolationMode: string(cluster.IsolationMode),
|
||||
DefaultNamespace: cluster.DefaultNamespace,
|
||||
IsShared: cluster.IsShared,
|
||||
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
@ -70,84 +67,3 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// WorkspaceDTOFromEntity 转换 Workspace 实体为 DTO
|
||||
func WorkspaceDTOFromEntity(workspace *entity.Workspace) *WorkspaceDTO {
|
||||
return &WorkspaceDTO{
|
||||
ID: workspace.ID,
|
||||
Name: workspace.Name,
|
||||
ClusterIDs: workspace.ClusterIDs,
|
||||
Description: workspace.Description,
|
||||
CreatedBy: workspace.CreatedBy,
|
||||
CreatedAt: workspace.CreatedAt,
|
||||
UpdatedAt: workspace.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// WorkspaceDTOsFromEntities 批量转换
|
||||
func WorkspaceDTOsFromEntities(workspaces []*entity.Workspace) []*WorkspaceDTO {
|
||||
result := make([]*WorkspaceDTO, len(workspaces))
|
||||
for i, w := range workspaces {
|
||||
result[i] = WorkspaceDTOFromEntity(w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// QuotaDTOFromEntity 转换 Quota 实体为 DTO
|
||||
func QuotaDTOFromEntity(quota *entity.WorkspaceQuota) *QuotaDTO {
|
||||
return &QuotaDTO{
|
||||
ID: quota.ID,
|
||||
WorkspaceID: quota.WorkspaceID,
|
||||
ResourceType: string(quota.ResourceType),
|
||||
HardLimit: quota.HardLimit,
|
||||
SoftLimit: quota.SoftLimit,
|
||||
Used: quota.Used,
|
||||
}
|
||||
}
|
||||
|
||||
// QuotaDTOsFromEntities 批量转换
|
||||
func QuotaDTOsFromEntities(quotas []*entity.WorkspaceQuota) []*QuotaDTO {
|
||||
result := make([]*QuotaDTO, len(quotas))
|
||||
for i, q := range quotas {
|
||||
result[i] = QuotaDTOFromEntity(q)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UserDTOFromEntity 转换 User 实体为 DTO
|
||||
func UserDTOFromEntity(user *entity.User, workspaceName string) *UserDTO {
|
||||
return &UserDTO{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: string(user.Role),
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName,
|
||||
IsActive: user.IsActive,
|
||||
MustChangePassword: user.MustChangePassword,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// UserDTOsFromEntities 批量转换
|
||||
func UserDTOsFromEntities(users []*entity.User, workspaceNames map[string]string) []*UserDTO {
|
||||
result := make([]*UserDTO, len(users))
|
||||
for i, u := range users {
|
||||
workspaceName := ""
|
||||
if u.WorkspaceID != "" {
|
||||
workspaceName = workspaceNames[u.WorkspaceID]
|
||||
}
|
||||
result[i] = UserDTOFromEntity(u, workspaceName)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TimeToString 转换时间
|
||||
func TimeToString(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
|
||||
@ -12,4 +12,3 @@ type SuccessResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -2,24 +2,25 @@ package dto
|
||||
|
||||
// CreateInstanceRequest 创建实例请求
|
||||
type CreateInstanceRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Namespace string `json:"namespace" binding:"required"`
|
||||
RegistryID string `json:"registryId" binding:"required"`
|
||||
RegistryIDAlt string `json:"registry_id"`
|
||||
Repository string `json:"repository" binding:"required"`
|
||||
Tag string `json:"tag"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Namespace string `json:"namespace" binding:"required"`
|
||||
RegistryID string `json:"registryId" binding:"required"`
|
||||
RegistryIDAlt string `json:"registry_id"`
|
||||
Repository string `json:"repository" binding:"required"`
|
||||
Tag string `json:"tag" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
ValuesYAMLAlt string `json:"values_yaml"`
|
||||
}
|
||||
|
||||
// UpdateInstanceRequest 更新实例请求
|
||||
type UpdateInstanceRequest struct {
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
ValuesYAMLAlt string `json:"values_yaml"`
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
@ -27,9 +28,15 @@ func (r *CreateInstanceRequest) Normalize() {
|
||||
if r.RegistryID == "" {
|
||||
r.RegistryID = r.RegistryIDAlt
|
||||
}
|
||||
// Support both "tag" and "version" field names from frontend
|
||||
if r.Tag == "" {
|
||||
r.Tag = r.Version
|
||||
if r.ValuesYAML == "" {
|
||||
r.ValuesYAML = r.ValuesYAMLAlt
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
func (r *UpdateInstanceRequest) Normalize() {
|
||||
if r.ValuesYAML == "" {
|
||||
r.ValuesYAML = r.ValuesYAMLAlt
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,23 +55,27 @@ type DeleteInstanceRequest struct {
|
||||
|
||||
// InstanceResponse 实例响应
|
||||
type InstanceResponse struct {
|
||||
ID string `json:"id"`
|
||||
ClusterID string `json:"clusterId"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
RegistryID string `json:"registryId"`
|
||||
Repository string `json:"repository"`
|
||||
Chart string `json:"chart"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
StatusReason string `json:"statusReason,omitempty"`
|
||||
LastOperation string `json:"lastOperation,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
Values map[string]interface{} `json:"values,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
ClusterID string `json:"clusterId"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
RegistryID string `json:"registryId"`
|
||||
Repository string `json:"repository"`
|
||||
Chart string `json:"chart"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||
StatusReason string `json:"statusReason,omitempty"`
|
||||
LastOperation string `json:"lastOperation,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
Values map[string]interface{} `json:"values,omitempty"`
|
||||
Replicas int `json:"replicas"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// InstanceStatusResponse 实例状态响应
|
||||
@ -136,3 +147,89 @@ type InstanceEntryResponse struct {
|
||||
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
|
||||
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceDiagnosticsResponse struct {
|
||||
InstanceName string `json:"instanceName"`
|
||||
Namespace string `json:"namespace"`
|
||||
Pods []InstancePodDiagnostics `json:"pods"`
|
||||
Services []InstanceServiceDiagnostics `json:"services"`
|
||||
Events []InstanceEventDiagnostics `json:"events"`
|
||||
Logs []InstancePodLogResponse `json:"logs"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
}
|
||||
|
||||
type InstancePodDiagnostics struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Phase string `json:"phase"`
|
||||
NodeName string `json:"nodeName,omitempty"`
|
||||
PodIP string `json:"podIp,omitempty"`
|
||||
HostIP string `json:"hostIp,omitempty"`
|
||||
RestartCount int32 `json:"restartCount"`
|
||||
Containers []InstanceContainerDiagnostics `json:"containers"`
|
||||
Conditions []InstanceConditionDiagnostics `json:"conditions"`
|
||||
CreationTimestamp string `json:"creationTimestamp,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceContainerDiagnostics struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Ready bool `json:"ready"`
|
||||
RestartCount int32 `json:"restartCount"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceConditionDiagnostics struct {
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceServiceDiagnostics struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Type string `json:"type"`
|
||||
ClusterIP string `json:"clusterIP,omitempty"`
|
||||
Ports []InstanceEntryPortResponse `json:"ports,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceEventDiagnostics struct {
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
InvolvedKind string `json:"involvedKind"`
|
||||
InvolvedName string `json:"involvedName"`
|
||||
Count int32 `json:"count"`
|
||||
FirstTimestamp string `json:"firstTimestamp,omitempty"`
|
||||
LastTimestamp string `json:"lastTimestamp,omitempty"`
|
||||
}
|
||||
|
||||
// ScaleInstanceRequest 扩缩容实例请求
|
||||
type ScaleInstanceRequest struct {
|
||||
Replicas int `json:"replicas" binding:"required"`
|
||||
Workload string `json:"workload"`
|
||||
}
|
||||
|
||||
// ScaleInstanceResponse 扩缩容实例响应
|
||||
type ScaleInstanceResponse struct {
|
||||
Instance *InstanceResponse `json:"instance"`
|
||||
Replicas int `json:"replicas"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// InstanceValuesDiffResponse 实例 values 差异响应
|
||||
type InstanceValuesDiffResponse struct {
|
||||
Current map[string]interface{} `json:"current"`
|
||||
Defaults map[string]interface{} `json:"defaults"`
|
||||
}
|
||||
|
||||
type InstancePodLogResponse struct {
|
||||
Pod string `json:"pod"`
|
||||
Container string `json:"container"`
|
||||
TailLines int64 `json:"tailLines"`
|
||||
Log string `json:"log,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@ -8,29 +8,29 @@ import (
|
||||
|
||||
// ClusterMetricsResponse 集群监控响应
|
||||
type ClusterMetricsResponse struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
Status string `json:"status"`
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
PodCount int `json:"podCount"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
TotalCPU string `json:"totalCpu"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
TotalGPU int `json:"totalGpu"`
|
||||
UsedCPU string `json:"usedCpu"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
UsedGPU int `json:"usedGpu"`
|
||||
CPUUsage float64 `json:"cpuUsage"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
GPUUsage float64 `json:"gpuUsage"`
|
||||
MaxNodeCPU string `json:"maxNodeCpu"`
|
||||
MaxNodeMemory string `json:"maxNodeMemory"`
|
||||
MaxNodeGPU int `json:"maxNodeGpu"`
|
||||
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
||||
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
||||
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
||||
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
||||
ClusterID string `json:"clusterId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
Status string `json:"status"`
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
PodCount int `json:"podCount"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
TotalCPU string `json:"totalCpu"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
TotalGPU int `json:"totalGpu"`
|
||||
UsedCPU string `json:"usedCpu"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
UsedGPU int `json:"usedGpu"`
|
||||
CPUUsage float64 `json:"cpuUsage"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
GPUUsage float64 `json:"gpuUsage"`
|
||||
MaxNodeCPU string `json:"maxNodeCpu"`
|
||||
MaxNodeMemory string `json:"maxNodeMemory"`
|
||||
MaxNodeGPU int `json:"maxNodeGpu"`
|
||||
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
||||
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
||||
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
||||
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMetricsResponse 节点监控响应
|
||||
@ -72,28 +72,28 @@ type MonitoringSummaryResponse struct {
|
||||
// ToClusterMetricsResponse 转换为响应
|
||||
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
|
||||
resp := &ClusterMetricsResponse{
|
||||
ClusterID: m.ClusterID,
|
||||
ClusterName: m.ClusterName,
|
||||
Status: m.Status,
|
||||
Uptime: m.Uptime,
|
||||
NodeCount: m.NodeCount,
|
||||
PodCount: m.PodCount,
|
||||
LastCheck: m.LastCheck,
|
||||
TotalCPU: m.TotalCPU,
|
||||
TotalMemory: m.TotalMemory,
|
||||
TotalGPU: m.TotalGPU,
|
||||
UsedCPU: m.UsedCPU,
|
||||
UsedMemory: m.UsedMemory,
|
||||
UsedGPU: m.UsedGPU,
|
||||
CPUUsage: m.CPUUsage,
|
||||
MemoryUsage: m.MemoryUsage,
|
||||
GPUUsage: m.GPUUsage,
|
||||
MaxNodeCPU: m.MaxNodeCPU,
|
||||
MaxNodeMemory: m.MaxNodeMemory,
|
||||
MaxNodeGPU: m.MaxNodeGPU,
|
||||
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
||||
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
||||
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
||||
ClusterID: m.ClusterID,
|
||||
ClusterName: m.ClusterName,
|
||||
Status: m.Status,
|
||||
Uptime: m.Uptime,
|
||||
NodeCount: m.NodeCount,
|
||||
PodCount: m.PodCount,
|
||||
LastCheck: m.LastCheck,
|
||||
TotalCPU: m.TotalCPU,
|
||||
TotalMemory: m.TotalMemory,
|
||||
TotalGPU: m.TotalGPU,
|
||||
UsedCPU: m.UsedCPU,
|
||||
UsedMemory: m.UsedMemory,
|
||||
UsedGPU: m.UsedGPU,
|
||||
CPUUsage: m.CPUUsage,
|
||||
MemoryUsage: m.MemoryUsage,
|
||||
GPUUsage: m.GPUUsage,
|
||||
MaxNodeCPU: m.MaxNodeCPU,
|
||||
MaxNodeMemory: m.MaxNodeMemory,
|
||||
MaxNodeGPU: m.MaxNodeGPU,
|
||||
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
||||
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
||||
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
||||
}
|
||||
|
||||
if len(m.Nodes) > 0 {
|
||||
@ -140,4 +140,3 @@ func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummary
|
||||
LastUpdate: s.LastUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,39 +2,46 @@ package dto
|
||||
|
||||
// CreateRegistryRequest 创建 Registry 请求
|
||||
type CreateRegistryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
Visibility string `json:"visibility"`
|
||||
GlobalShared bool `json:"globalShared"`
|
||||
GlobalSharedAlt bool `json:"global_shared"`
|
||||
}
|
||||
|
||||
// UpdateRegistryRequest 更新 Registry 请求
|
||||
type UpdateRegistryRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
Visibility string `json:"visibility"`
|
||||
GlobalShared bool `json:"globalShared"`
|
||||
GlobalSharedAlt bool `json:"global_shared"`
|
||||
}
|
||||
|
||||
// RegistryResponse Registry 响应(敏感数据已脱敏)
|
||||
type RegistryResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
||||
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
||||
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
||||
IsShared bool `json:"is_shared"`
|
||||
Insecure bool `json:"insecure"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Visibility string `json:"visibility"`
|
||||
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
||||
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
||||
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
||||
Insecure bool `json:"insecure"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RegistryHealthResponse Registry 健康状态响应
|
||||
@ -42,4 +49,3 @@ type RegistryHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
package dto
|
||||
|
||||
// CreateStorageRequest 创建存储后端请求
|
||||
type CreateStorageRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"` // nfs, pv, hostPath
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
|
||||
|
||||
// NFS 配置
|
||||
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
||||
// PV 配置
|
||||
PV PVConfigDTO `json:"pv,omitempty"`
|
||||
// HostPath 配置
|
||||
HostPath HostPathConfigDTO `json:"hostPath,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateStorageRequest 更新存储后端请求
|
||||
type UpdateStorageRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
|
||||
|
||||
// NFS 配置
|
||||
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
||||
// PV 配置
|
||||
PV PVConfigDTO `json:"pv,omitempty"`
|
||||
// HostPath 配置
|
||||
HostPath HostPathConfigDTO `json:"hostPath,omitempty"`
|
||||
}
|
||||
|
||||
// NFSConfigDTO NFS 配置
|
||||
type NFSConfigDTO struct {
|
||||
Server string `json:"server"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// PVConfigDTO PV 配置
|
||||
type PVConfigDTO struct {
|
||||
StorageClassName string `json:"storageClassName"`
|
||||
Capacity string `json:"capacity"`
|
||||
AccessModes []string `json:"accessModes"`
|
||||
}
|
||||
|
||||
// HostPathConfigDTO HostPath 配置
|
||||
type HostPathConfigDTO struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// StorageResponse 存储后端响应
|
||||
type StorageResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
ClusterID string `json:"cluster_id,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config StorageConfigDTO `json:"config"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// StorageConfigDTO 存储配置(脱敏后)
|
||||
type StorageConfigDTO struct {
|
||||
NFS *NFSConfigDTO `json:"nfs,omitempty"`
|
||||
PV *PVConfigDTO `json:"pv,omitempty"`
|
||||
HostPath *HostPathConfigDTO `json:"hostPath,omitempty"`
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// UserDTO 用户 DTO
|
||||
type UserDTO struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
WorkspaceName string `json:"workspace_name,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateUserRequest 创建用户请求(Admin 操作)
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role" validate:"required,oneof=admin user"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest 更新用户请求
|
||||
type UpdateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ChangeUserWorkspaceRequest 分配用户到 Workspace 请求
|
||||
type ChangeUserWorkspaceRequest struct {
|
||||
WorkspaceID string `json:"workspace_id" validate:"required"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest 重置密码请求
|
||||
type ResetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// SetUserActiveRequest 启用/禁用用户请求
|
||||
type SetUserActiveRequest struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UserListResponse 用户列表响应
|
||||
type UserListResponse struct {
|
||||
Users []*UserDTO `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// UserWithWorkspaceResponse 用户及其 Workspace 响应
|
||||
type UserWithWorkspaceResponse struct {
|
||||
User *UserDTO `json:"user"`
|
||||
Workspace *WorkspaceDTO `json:"workspace,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
// UserResponseWithDTO 用户响应(包含完整DTO)
|
||||
type UserResponseWithDTO struct {
|
||||
User *UserDTO `json:"user"`
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
package dto
|
||||
|
||||
// CreateValuesTemplateRequest 创建 Values 模板请求
|
||||
type CreateValuesTemplateRequest struct {
|
||||
ChartReferenceID string `json:"chart_reference_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ValuesYAML string `json:"values_yaml" binding:"required"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// UpdateValuesTemplateRequest 更新 Values 模板请求
|
||||
type UpdateValuesTemplateRequest struct {
|
||||
Description string `json:"description"`
|
||||
ValuesYAML string `json:"values_yaml"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// ValuesTemplateResponse Values 模板响应
|
||||
type ValuesTemplateResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
ChartReferenceID string `json:"chart_reference_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ValuesYAML string `json:"values_yaml"`
|
||||
Version int `json:"version"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RollbackValuesTemplateRequest 回滚请求
|
||||
type RollbackValuesTemplateRequest struct {
|
||||
TemplateID string `json:"template_id" binding:"required"`
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkspaceDTO 工作空间 DTO
|
||||
type WorkspaceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClusterIDs []string `json:"cluster_ids,omitempty"`
|
||||
Quotas []*QuotaDTO `json:"quotas,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateWorkspaceRequest 创建工作空间请求(包含配额设置)
|
||||
type CreateWorkspaceRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
ClusterIDs []string `json:"cluster_ids"`
|
||||
// Quotas can be set during creation
|
||||
CPU *QuotaValue `json:"cpu"`
|
||||
GPU *QuotaValue `json:"gpu"`
|
||||
GPUMemory *QuotaValue `json:"gpu_memory"`
|
||||
}
|
||||
|
||||
// UpdateWorkspaceRequest 更新工作空间请求
|
||||
type UpdateWorkspaceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ClusterIDs []string `json:"cluster_ids"`
|
||||
}
|
||||
|
||||
// QuotaDTO 配额 DTO
|
||||
type QuotaDTO struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
HardLimit float64 `json:"hard_limit"`
|
||||
SoftLimit float64 `json:"soft_limit"`
|
||||
Used float64 `json:"used"`
|
||||
}
|
||||
|
||||
// SetQuotaRequest 设置配额请求
|
||||
type SetQuotaRequest struct {
|
||||
ResourceType string `json:"resource_type" validate:"required"`
|
||||
HardLimit float64 `json:"hard_limit" validate:"required"`
|
||||
SoftLimit float64 `json:"soft_limit"`
|
||||
}
|
||||
|
||||
// SetQuotasRequest 批量设置配额请求
|
||||
type SetQuotasRequest struct {
|
||||
CPU *QuotaValue `json:"cpu"`
|
||||
GPU *QuotaValue `json:"gpu"`
|
||||
GPUMemory *QuotaValue `json:"gpu_memory"`
|
||||
}
|
||||
|
||||
// QuotaValue 配额值
|
||||
type QuotaValue struct {
|
||||
HardLimit float64 `json:"hard_limit"`
|
||||
SoftLimit float64 `json:"soft_limit"`
|
||||
}
|
||||
|
||||
// WorkspaceResponse 工作空间响应
|
||||
type WorkspaceResponse struct {
|
||||
Workspace *WorkspaceDTO `json:"workspace"`
|
||||
Quotas []*QuotaDTO `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceListResponse 工作空间列表响应
|
||||
type WorkspaceListResponse struct {
|
||||
Workspaces []*WorkspaceDTO `json:"workspaces"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@ -1,284 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// Context keys
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ContextKeyUserID contextKey = "user_id"
|
||||
ContextKeyUsername contextKey = "username"
|
||||
ContextKeyUserRole contextKey = "user_role"
|
||||
ContextKeyWorkspaceID contextKey = "workspace_id"
|
||||
)
|
||||
|
||||
// UserClaims 用户声明(从 JWT 解析)
|
||||
type UserClaims struct {
|
||||
UserID string
|
||||
Username string
|
||||
Role entity.UserRole
|
||||
WorkspaceID string
|
||||
}
|
||||
|
||||
// WorkspaceMiddleware 工作空间中间件
|
||||
// 从 JWT 获取用户角色和 workspace_id,进行权限检查
|
||||
func WorkspaceMiddleware(userRepo repository.UserRepository) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 从 Header 获取 Token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token := parts[1]
|
||||
_ = token
|
||||
|
||||
// 这里需要从 AuthService 获取验证方法
|
||||
// 简化处理:假设 token 包含 user_id 和 username
|
||||
// 实际实现需要调用 JWT 验证服务
|
||||
|
||||
// 从数据库获取用户信息
|
||||
// 注意:这里需要通过 token 解析出 userID
|
||||
// 实际实现应该在 AuthService 中完成
|
||||
_ = userRepo
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireWorkspace 强制要求 workspace 上下文
|
||||
// 用于非 Admin 用户的资源操作
|
||||
func RequireWorkspace(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
|
||||
// Admin 可以没有 workspace
|
||||
if userRole == string(entity.RoleAdmin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 普通用户必须有 workspace
|
||||
if workspaceID == "" {
|
||||
http.Error(w, "Workspace context required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 将 workspace_id 放入 context
|
||||
ctx := context.WithValue(r.Context(), ContextKeyWorkspaceID, workspaceID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAdmin 要求 Admin 角色
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
|
||||
if userRole != string(entity.RoleAdmin) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserClaims 从 Context 获取用户声明
|
||||
func GetUserClaims(ctx context.Context) *UserClaims {
|
||||
userID, _ := ctx.Value(ContextKeyUserID).(string)
|
||||
username, _ := ctx.Value(ContextKeyUsername).(string)
|
||||
roleStr, _ := ctx.Value(ContextKeyUserRole).(string)
|
||||
workspaceID, _ := ctx.Value(ContextKeyWorkspaceID).(string)
|
||||
|
||||
return &UserClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: entity.UserRole(roleStr),
|
||||
WorkspaceID: workspaceID,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspaceID 从 Context 获取 workspace ID
|
||||
func GetWorkspaceID(ctx context.Context) string {
|
||||
workspaceID, _ := ctx.Value(ContextKeyWorkspaceID).(string)
|
||||
return workspaceID
|
||||
}
|
||||
|
||||
// GetUserID 从 Context 获取用户 ID
|
||||
func GetUserID(ctx context.Context) string {
|
||||
userID, _ := ctx.Value(ContextKeyUserID).(string)
|
||||
return userID
|
||||
}
|
||||
|
||||
// GetUserRole 从 Context 获取用户角色
|
||||
func GetUserRole(ctx context.Context) entity.UserRole {
|
||||
roleStr, _ := ctx.Value(ContextKeyUserRole).(string)
|
||||
return entity.UserRole(roleStr)
|
||||
}
|
||||
|
||||
// FilterByWorkspace 根据用户角色过滤资源
|
||||
// Admin: 返回所有资源(workspaceID 忽略)
|
||||
// User: 仅返回属于自己 workspace 的资源
|
||||
func FilterByWorkspace(workspaceID, userRole string) (filterWorkspaceID string, isAdmin bool) {
|
||||
if userRole == string(entity.RoleAdmin) {
|
||||
return "", true
|
||||
}
|
||||
return workspaceID, false
|
||||
}
|
||||
|
||||
// AuthorizationService 授权服务
|
||||
type AuthorizationService struct {
|
||||
userRepo repository.UserRepository
|
||||
}
|
||||
|
||||
// NewAuthorizationService 创建授权服务
|
||||
func NewAuthorizationService(userRepo repository.UserRepository) *AuthorizationService {
|
||||
return &AuthorizationService{
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckResourceAccess 检查用户是否有权访问指定资源
|
||||
func (s *AuthorizationService) CheckResourceAccess(ctx context.Context, userID, resourceWorkspaceID string) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin 可以访问所有资源
|
||||
if user.Role == entity.RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通用户只能访问自己 workspace 的资源
|
||||
if user.WorkspaceID != resourceWorkspaceID {
|
||||
return entity.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanAccessWorkspace 检查用户是否可以访问指定 workspace
|
||||
func (s *AuthorizationService) CanAccessWorkspace(ctx context.Context, userID, targetWorkspaceID string) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin 可以访问所有 workspace
|
||||
if user.Role == entity.RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通用户只能访问自己的 workspace
|
||||
if user.WorkspaceID != targetWorkspaceID {
|
||||
return entity.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAccessibleWorkspaces 获取用户可访问的 workspace 列表
|
||||
func (s *AuthorizationService) GetAccessibleWorkspaces(ctx context.Context, userID string) ([]string, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Admin 可以访问所有 workspace
|
||||
if user.Role == entity.RoleAdmin {
|
||||
return nil, nil // nil 表示所有
|
||||
}
|
||||
|
||||
// 普通用户只能访问自己的 workspace
|
||||
if user.WorkspaceID != "" {
|
||||
return []string{user.WorkspaceID}, nil
|
||||
}
|
||||
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// RequireRole 要求特定角色
|
||||
func RequireRole(roles ...entity.UserRole) func(http.Handler) http.Handler {
|
||||
roleSet := make(map[entity.UserRole]bool)
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
|
||||
if !roleSet[entity.UserRole(userRole)] {
|
||||
http.Error(w, "Insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserClaims 将用户声明注入到 Context
|
||||
func WithUserClaims(claims *UserClaims) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ContextKeyUserID, claims.UserID)
|
||||
ctx = context.WithValue(ctx, ContextKeyUsername, claims.Username)
|
||||
ctx = context.WithValue(ctx, ContextKeyUserRole, string(claims.Role))
|
||||
if claims.WorkspaceID != "" {
|
||||
ctx = context.WithValue(ctx, ContextKeyWorkspaceID, claims.WorkspaceID)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequired 要求登录
|
||||
func LoginRequired(authService *service.AuthService) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
userID, _, err := authService.VerifyAccessToken(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -29,14 +29,19 @@ func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandl
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param artifact_type query string false "Artifact type filter (chart, all)" default(chart)
|
||||
// @Success 200 {object} dto.RepositoryListResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories [get]
|
||||
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
artifactType := r.URL.Query().Get("artifact_type")
|
||||
if artifactType == "" {
|
||||
artifactType = "chart"
|
||||
}
|
||||
|
||||
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
|
||||
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID, artifactType)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
|
||||
return
|
||||
@ -50,13 +55,17 @@ func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
// Determine source and message based on repository count
|
||||
source := "catalog"
|
||||
source := "harbor-api"
|
||||
catalogSupported := true
|
||||
message := ""
|
||||
|
||||
if len(repositories) == 0 {
|
||||
source = "unavailable"
|
||||
message = "No repositories found in this registry"
|
||||
if artifactType == "chart" {
|
||||
message = "No chart repositories found in this registry"
|
||||
} else {
|
||||
message = "No repositories found in this registry"
|
||||
}
|
||||
}
|
||||
|
||||
response := &dto.RepositoryListResponse{
|
||||
@ -192,41 +201,36 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetArtifactValues 获取 Helm Chart 的 values.yaml
|
||||
// @Summary 获取 Helm Chart Values
|
||||
// @Description 获取 Helm Chart 的 values.yaml 文件内容 (仅支持 Chart 类型)
|
||||
// GetArtifactValuesYAML 获取 Helm Chart 的默认 values.yaml
|
||||
// @Summary 获取 Helm Chart 默认 Values YAML
|
||||
// @Description 获取 Helm Chart 包内原始 values.yaml,用于高级覆盖编辑
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||
// @Success 200 {object} dto.ValuesResponse
|
||||
// @Success 200 {object} dto.ValuesYAMLResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values [get]
|
||||
func (h *ArtifactHandler) GetArtifactValues(w http.ResponseWriter, r *http.Request) {
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-yaml [get]
|
||||
func (h *ArtifactHandler) GetArtifactValuesYAML(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
repositoryName := vars["repository_name"]
|
||||
reference := vars["reference"]
|
||||
|
||||
values, err := h.artifactService.GetValues(r.Context(), registryID, repositoryName, reference)
|
||||
valuesYAML, err := h.artifactService.GetValuesYAML(r.Context(), registryID, repositoryName, reference)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, entity.ErrRegistryNotFound),
|
||||
errors.Is(err, entity.ErrRepositoryNotFound),
|
||||
errors.Is(err, entity.ErrArtifactNotFound),
|
||||
errors.Is(err, entity.ErrValuesNotFound):
|
||||
respondError(w, http.StatusNotFound, "Values not found", err.Error())
|
||||
errors.Is(err, entity.ErrArtifactNotFound):
|
||||
respondError(w, http.StatusNotFound, "Values YAML not found", err.Error())
|
||||
default:
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get values", err.Error())
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get values YAML", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.ValuesResponse{
|
||||
Values: values,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
respondJSON(w, http.StatusOK, &dto.ValuesYAMLResponse{ValuesYAML: valuesYAML})
|
||||
}
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
)
|
||||
|
||||
// AuthHandler 认证 Handler
|
||||
@ -20,9 +25,9 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// @Summary 用户注册
|
||||
// @Description 创建一个新的后台用户
|
||||
// Register 管理员创建用户
|
||||
// @Summary 管理员创建用户
|
||||
// @Description 创建一个新的后台用户。公开自注册已禁用,只允许 admin 调用。
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@ -38,22 +43,64 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
user, err := h.authService.Register(r.Context(), req.Username, req.Password)
|
||||
user, err := h.authService.Register(r.Context(), req.Username, req.Password, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||
Namespace: req.Namespace,
|
||||
DefaultClusterID: req.DefaultClusterID,
|
||||
QuotaCPU: req.QuotaCPU,
|
||||
QuotaMemory: req.QuotaMemory,
|
||||
QuotaGPU: req.QuotaGPU,
|
||||
QuotaGPUMem: req.QuotaGPUMem,
|
||||
}, req.IsActive, req.MustChangePassword)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
|
||||
respondServiceError(w, err, "Registration failed")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := &dto.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, h.convertUserResponse(r.Context(), user))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
func (h *AuthHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.authService.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to list users")
|
||||
return
|
||||
}
|
||||
responses := make([]*dto.UserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
responses = append(responses, h.convertUserResponse(r.Context(), user))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := mux.Vars(r)["user_id"]
|
||||
var req dto.UpdateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
user, err := h.authService.UpdateUser(r.Context(), userID, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||
Namespace: req.Namespace,
|
||||
DefaultClusterID: req.DefaultClusterID,
|
||||
QuotaCPU: req.QuotaCPU,
|
||||
QuotaMemory: req.QuotaMemory,
|
||||
QuotaGPU: req.QuotaGPU,
|
||||
QuotaGPUMem: req.QuotaGPUMem,
|
||||
}, req.IsActive, req.MustChangePassword)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to update user")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, h.convertUserResponse(r.Context(), user))
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := mux.Vars(r)["user_id"]
|
||||
if err := h.authService.DeleteUser(r.Context(), userID); err != nil {
|
||||
respondServiceError(w, err, "Failed to delete user")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
@ -74,23 +121,56 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||
accessToken, refreshToken, user, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
// TODO: 从 token 解析用户信息或从服务获取
|
||||
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
|
||||
|
||||
// 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致
|
||||
// 返回响应
|
||||
response := &dto.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
Username: req.Username,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName(workspace),
|
||||
Namespace: workspaceNamespace(workspace),
|
||||
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||
Permissions: authz.PermissionsForRole(user.Role),
|
||||
PermissionVersion: 1,
|
||||
}
|
||||
|
||||
respondSuccess(w, "Login successful", response)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse {
|
||||
workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID)
|
||||
return &dto.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName(workspace),
|
||||
Namespace: workspaceNamespace(workspace),
|
||||
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||
IsActive: user.IsActive,
|
||||
MustChangePassword: user.MustChangePassword,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
@ -111,17 +191,109 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
||||
newAccessToken, user, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
||||
return
|
||||
}
|
||||
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
|
||||
|
||||
// 返回响应 - 使用 respondSuccess 包装
|
||||
// 返回响应
|
||||
response := &dto.AuthResponse{
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: req.RefreshToken,
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: req.RefreshToken,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName(workspace),
|
||||
Namespace: workspaceNamespace(workspace),
|
||||
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||
Permissions: authz.PermissionsForRole(user.Role),
|
||||
PermissionVersion: 1,
|
||||
}
|
||||
|
||||
respondSuccess(w, "Token refreshed", response)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
if token == "" || token == header {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||
return
|
||||
}
|
||||
principal, err := h.authService.VerifyAccessToken(r.Context(), token)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, &dto.AuthResponse{
|
||||
UserID: principal.UserID,
|
||||
Username: principal.Username,
|
||||
Role: principal.Role,
|
||||
WorkspaceID: principal.WorkspaceID,
|
||||
WorkspaceName: principal.WorkspaceName,
|
||||
Namespace: principal.Namespace,
|
||||
DefaultClusterID: principal.DefaultClusterID,
|
||||
QuotaCPU: principal.QuotaCPU,
|
||||
QuotaMemory: principal.QuotaMemory,
|
||||
QuotaGPU: principal.QuotaGPU,
|
||||
QuotaGPUMem: principal.QuotaGPUMem,
|
||||
Permissions: principal.Permissions,
|
||||
PermissionVersion: principal.PermissionVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func workspaceName(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.Name
|
||||
}
|
||||
|
||||
func workspaceNamespace(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.K8sNamespace
|
||||
}
|
||||
|
||||
func workspaceDefaultClusterID(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.DefaultClusterID
|
||||
}
|
||||
|
||||
func workspaceQuotaCPU(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.QuotaCPU
|
||||
}
|
||||
|
||||
func workspaceQuotaMemory(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.QuotaMemory
|
||||
}
|
||||
|
||||
func workspaceQuotaGPU(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.QuotaGPU
|
||||
}
|
||||
|
||||
func workspaceQuotaGPUMem(workspace *entity.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.QuotaGPUMem
|
||||
}
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// ChartReferenceHandler Chart Reference Handler
|
||||
type ChartReferenceHandler struct {
|
||||
chartRefService *service.ChartReferenceService
|
||||
}
|
||||
|
||||
// NewChartReferenceHandler 创建 Chart Reference Handler
|
||||
func NewChartReferenceHandler(chartRefService *service.ChartReferenceService) *ChartReferenceHandler {
|
||||
return &ChartReferenceHandler{
|
||||
chartRefService: chartRefService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChartReference 创建 Chart 引用
|
||||
// @Summary 创建 Chart 引用
|
||||
// @Description 新增 Chart 引用配置
|
||||
// @Tags Chart References
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.CreateChartReferenceRequest true "Chart 引用信息"
|
||||
// @Success 201 {object} dto.ChartReferenceResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /chart-references [post]
|
||||
func (h *ChartReferenceHandler) CreateChartReference(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateChartReferenceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
chartRef, err := h.chartRefService.Create(
|
||||
r.Context(),
|
||||
workspaceID,
|
||||
req.RegistryID,
|
||||
req.Repository,
|
||||
req.ChartName,
|
||||
req.Description,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create chart reference", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toChartReferenceResponse(chartRef)
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetChartReference 获取 Chart 引用详情
|
||||
// @Summary 获取 Chart 引用
|
||||
// @Tags Chart References
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Success 200 {object} dto.ChartReferenceResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id} [get]
|
||||
func (h *ChartReferenceHandler) GetChartReference(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
chartRefID := vars["chart_reference_id"]
|
||||
|
||||
chartRef, err := h.chartRefService.GetByID(r.Context(), chartRefID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Chart reference not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toChartReferenceResponse(chartRef)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllChartReferences 获取所有 Chart 引用
|
||||
// @Summary 列出所有 Chart 引用
|
||||
// @Tags Chart References
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.ChartReferenceResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /chart-references [get]
|
||||
func (h *ChartReferenceHandler) GetAllChartReferences(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
role := r.Header.Get("X-User-Role")
|
||||
|
||||
var chartRefs []*dto.ChartReferenceResponse
|
||||
|
||||
// Admin 可以看到所有,其他用户只看自己 workspace
|
||||
if role == "admin" {
|
||||
allChartRefs, err := h.chartRefService.List(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
|
||||
return
|
||||
}
|
||||
for _, cr := range allChartRefs {
|
||||
chartRefs = append(chartRefs, toChartReferenceResponse(cr))
|
||||
}
|
||||
} else if workspaceID != "" {
|
||||
workspaceChartRefs, err := h.chartRefService.GetByWorkspace(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
|
||||
return
|
||||
}
|
||||
for _, cr := range workspaceChartRefs {
|
||||
chartRefs = append(chartRefs, toChartReferenceResponse(cr))
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, chartRefs)
|
||||
}
|
||||
|
||||
// UpdateChartReference 更新 Chart 引用
|
||||
// @Summary 更新 Chart 引用
|
||||
// @Tags Chart References
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Param request body dto.UpdateChartReferenceRequest true "更新内容"
|
||||
// @Success 200 {object} dto.ChartReferenceResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id} [put]
|
||||
func (h *ChartReferenceHandler) UpdateChartReference(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
chartRefID := vars["chart_reference_id"]
|
||||
|
||||
var req dto.UpdateChartReferenceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
isEnabled := true
|
||||
if req.IsEnabled != nil {
|
||||
isEnabled = *req.IsEnabled
|
||||
}
|
||||
|
||||
chartRef, err := h.chartRefService.Update(
|
||||
r.Context(),
|
||||
chartRefID,
|
||||
req.RegistryID,
|
||||
req.Repository,
|
||||
req.ChartName,
|
||||
req.Description,
|
||||
isEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update chart reference", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toChartReferenceResponse(chartRef)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteChartReference 删除 Chart 引用
|
||||
// @Summary 删除 Chart 引用
|
||||
// @Tags Chart References
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id} [delete]
|
||||
func (h *ChartReferenceHandler) DeleteChartReference(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
chartRefID := vars["chart_reference_id"]
|
||||
|
||||
if err := h.chartRefService.Delete(r.Context(), chartRefID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete chart reference", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetChartReferencesByRegistry 获取 Registry 的所有 Chart 引用
|
||||
// @Summary 获取 Registry 的 Chart 引用
|
||||
// @Tags Chart References
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Success 200 {array} dto.ChartReferenceResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/chart-references [get]
|
||||
func (h *ChartReferenceHandler) GetChartReferencesByRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
chartRefs, err := h.chartRefService.GetByRegistry(r.Context(), registryID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.ChartReferenceResponse, 0, len(chartRefs))
|
||||
for _, cr := range chartRefs {
|
||||
responses = append(responses, toChartReferenceResponse(cr))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// toChartReferenceResponse 转换为响应 DTO
|
||||
func toChartReferenceResponse(chartRef *entity.ChartReference) *dto.ChartReferenceResponse {
|
||||
return &dto.ChartReferenceResponse{
|
||||
ID: chartRef.ID,
|
||||
WorkspaceID: chartRef.WorkspaceID,
|
||||
RegistryID: chartRef.RegistryID,
|
||||
Repository: chartRef.Repository,
|
||||
ChartName: chartRef.ChartName,
|
||||
Description: chartRef.Description,
|
||||
IsEnabled: chartRef.IsEnabled,
|
||||
CreatedAt: chartRef.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: chartRef.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
@ -40,20 +40,18 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
req.Normalize()
|
||||
|
||||
// 创建实体
|
||||
cluster := entity.NewCluster("", "", req.Name, req.Host)
|
||||
cluster := entity.NewCluster(req.Name, req.Host)
|
||||
cluster.Description = req.Description
|
||||
cluster.Visibility = req.Visibility
|
||||
if req.GlobalShared || req.GlobalSharedAlt {
|
||||
cluster.Visibility = "global_shared"
|
||||
}
|
||||
cluster.DefaultNamespace = req.DefaultNamespace
|
||||
|
||||
// 设置认证信息
|
||||
hasKubeconfig := req.CAData != "" && (len(req.CAData) > 100 && (req.CAData[:11] == "apiVersion:" || req.CAData[:5] == "kind:"))
|
||||
hasCertAuth := req.CertData != "" && req.KeyData != ""
|
||||
|
||||
if hasKubeconfig {
|
||||
// 使用完整的 kubeconfig 格式
|
||||
cluster.CAData = req.CAData
|
||||
} else if hasCertAuth {
|
||||
if req.CertData != "" && req.KeyData != "" {
|
||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||
} else if req.Token != "" {
|
||||
cluster.SetTokenAuth(req.Token)
|
||||
@ -64,18 +62,6 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=",
|
||||
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t",
|
||||
)
|
||||
} else {
|
||||
// 生产模式:没有提供凭证,尝试使用本地 kubeconfig
|
||||
// 不再返回错误,让 TestConnection 尝试使用本地 kubeconfig
|
||||
// cluster 保持空的认证信息,TestConnection 会使用 KUBECONFIG 环境变量
|
||||
}
|
||||
|
||||
// 测试集群连接(非 mock 模式下)
|
||||
if os.Getenv("ADAPTER_MODE") != "mock" {
|
||||
if err := h.clusterService.TestConnection(r.Context(), cluster); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to connect to cluster", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
@ -166,6 +152,15 @@ func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 更新字段
|
||||
cluster.Update(req.Name, req.Host, req.Description)
|
||||
if req.Visibility != "" {
|
||||
cluster.Visibility = req.Visibility
|
||||
}
|
||||
if req.GlobalShared || req.GlobalSharedAlt {
|
||||
cluster.Visibility = "global_shared"
|
||||
}
|
||||
if req.DefaultNamespace != "" {
|
||||
cluster.DefaultNamespace = req.DefaultNamespace
|
||||
}
|
||||
|
||||
if req.CertData != "" && req.KeyData != "" {
|
||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||
@ -217,24 +212,18 @@ func (h *ClusterHandler) GetClusterHealth(w http.ResponseWriter, r *http.Request
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
// 获取集群
|
||||
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||
// 检查集群是否存在
|
||||
_, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
err = h.clusterService.TestConnection(r.Context(), cluster)
|
||||
|
||||
// TODO: 实现真实的健康检查
|
||||
response := &dto.ClusterHealthResponse{
|
||||
Healthy: err == nil,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.Message = err.Error()
|
||||
} else {
|
||||
response.Message = "Cluster is healthy"
|
||||
Healthy: true,
|
||||
Message: "Cluster is healthy",
|
||||
Version: "v1.28.0",
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
|
||||
@ -2,13 +2,17 @@ package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// InstanceHandler 实例 Handler
|
||||
@ -45,10 +49,6 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
if req.Tag == "" {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request", "version/tag is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract chart name from repository (e.g., "charts/nginx" -> "nginx")
|
||||
chart := req.Repository
|
||||
@ -58,14 +58,10 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// 创建实体
|
||||
instance := entity.NewInstance(
|
||||
"", // workspaceID - will be set based on user
|
||||
"", // ownerID - will be set based on user
|
||||
clusterID,
|
||||
req.RegistryID,
|
||||
"", // chartReferenceID - not used in legacy API
|
||||
"", // valuesTemplateID - not used in legacy API
|
||||
req.Name,
|
||||
req.Namespace,
|
||||
req.RegistryID,
|
||||
req.Repository,
|
||||
chart, // Extracted chart name
|
||||
req.Tag, // Tag mapped to version
|
||||
@ -77,6 +73,14 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
instance.SetValuesYAML(req.ValuesYAML)
|
||||
if req.Values == nil {
|
||||
values, err := parseValuesYAML(req.ValuesYAML)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
|
||||
return
|
||||
}
|
||||
instance.SetValues(values)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
@ -85,28 +89,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
Values: instance.Values,
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
respondJSON(w, http.StatusCreated, convertInstanceResponse(instance, true))
|
||||
}
|
||||
|
||||
// GetInstance 获取实例详情
|
||||
@ -121,6 +104,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
|
||||
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||
@ -128,28 +112,12 @@ func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
Values: instance.Values,
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
if instance.ClusterID != clusterID {
|
||||
respondError(w, http.StatusNotFound, "Instance not found", "resource does not belong to cluster")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
|
||||
}
|
||||
|
||||
// ListInstances 列出集群的所有实例
|
||||
@ -167,31 +135,16 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
|
||||
respondServiceError(w, err, "Failed to list instances")
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich with running replicas from K8s
|
||||
instances = h.instanceService.EnrichReplicas(r.Context(), clusterID, instances)
|
||||
|
||||
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
responses = append(responses, &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
Values: instance.Values,
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
responses = append(responses, convertInstanceResponse(instance, false))
|
||||
}
|
||||
|
||||
response := &dto.InstanceListResponse{
|
||||
@ -223,6 +176,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
|
||||
// 获取现有实例
|
||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||
@ -234,12 +188,22 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
||||
// 更新字段
|
||||
if req.Version != "" {
|
||||
instance.Upgrade(req.Version, req.Values)
|
||||
} else if req.Values != nil {
|
||||
instance.SetValues(req.Values)
|
||||
}
|
||||
if req.Description != "" {
|
||||
instance.Description = req.Description
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
instance.SetValuesYAML(req.ValuesYAML)
|
||||
if req.Values == nil {
|
||||
values, err := parseValuesYAML(req.ValuesYAML)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
|
||||
return
|
||||
}
|
||||
instance.SetValues(values)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
@ -248,27 +212,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
Values: instance.Values,
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
|
||||
}
|
||||
|
||||
// DeleteInstance 删除实例
|
||||
@ -329,6 +273,153 @@ func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Req
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
tailLines := int64(200)
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
|
||||
return
|
||||
}
|
||||
tailLines = parsed
|
||||
} else if raw := strings.TrimSpace(r.URL.Query().Get("tail_lines")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid tail_lines", "tail_lines must be a positive integer")
|
||||
return
|
||||
}
|
||||
tailLines = parsed
|
||||
}
|
||||
|
||||
diagnostics, err := h.instanceService.GetInstanceDiagnostics(r.Context(), clusterID, instanceID, tailLines)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch err {
|
||||
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
|
||||
status = http.StatusNotFound
|
||||
case entity.ErrForbidden:
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
respondError(w, status, "Failed to collect instance diagnostics", err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, convertInstanceDiagnostics(diagnostics))
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) StreamInstanceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
podName := strings.TrimSpace(r.URL.Query().Get("pod"))
|
||||
containerName := strings.TrimSpace(r.URL.Query().Get("container"))
|
||||
if podName == "" || containerName == "" {
|
||||
respondError(w, http.StatusBadRequest, "Missing required query parameter", "both 'pod' and 'container' are required")
|
||||
return
|
||||
}
|
||||
|
||||
tailLines := int64(200)
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
|
||||
return
|
||||
}
|
||||
tailLines = parsed
|
||||
}
|
||||
|
||||
lines, errs, err := h.instanceService.StreamInstanceLogs(r.Context(), clusterID, instanceID, podName, containerName, tailLines)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch err {
|
||||
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
respondError(w, status, "Failed to stream instance logs", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
respondError(w, http.StatusInternalServerError, "Streaming not supported", "server does not support response flushing")
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case line, open := <-lines:
|
||||
if !open {
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", line)
|
||||
flusher.Flush()
|
||||
case err, open := <-errs:
|
||||
if open && err != nil {
|
||||
fmt.Fprintf(w, "data: [ERROR] %s\n\n", err.Error())
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ScaleInstance 扩缩容实例
|
||||
func (h *InstanceHandler) ScaleInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
var req dto.ScaleInstanceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Replicas < 0 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid replicas", "replicas must be >= 0")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.instanceService.ScaleInstance(r.Context(), clusterID, instanceID, req.Replicas, req.Workload)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to scale instance")
|
||||
return
|
||||
}
|
||||
|
||||
instResp := convertInstanceResponse(result, true)
|
||||
instResp.Replicas = req.Replicas
|
||||
|
||||
respondJSON(w, http.StatusOK, dto.ScaleInstanceResponse{
|
||||
Instance: instResp,
|
||||
Replicas: req.Replicas,
|
||||
Message: fmt.Sprintf("Scaled to %d replicas", req.Replicas),
|
||||
})
|
||||
}
|
||||
|
||||
// GetInstanceValuesDiff 获取实例 values 差异
|
||||
func (h *InstanceHandler) GetInstanceValuesDiff(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
diff, err := h.instanceService.GetInstanceValuesDiff(r.Context(), clusterID, instanceID)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to get values diff")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, diff)
|
||||
}
|
||||
|
||||
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
|
||||
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
|
||||
for _, port := range entry.Ports {
|
||||
@ -378,3 +469,196 @@ func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryRespons
|
||||
TLS: tlsResponses,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInstanceDiagnostics(diagnostics *entity.InstanceDiagnostics) *dto.InstanceDiagnosticsResponse {
|
||||
if diagnostics == nil {
|
||||
return &dto.InstanceDiagnosticsResponse{}
|
||||
}
|
||||
pods := make([]dto.InstancePodDiagnostics, 0, len(diagnostics.Pods))
|
||||
for _, pod := range diagnostics.Pods {
|
||||
containers := make([]dto.InstanceContainerDiagnostics, 0, len(pod.Containers))
|
||||
for _, container := range pod.Containers {
|
||||
containers = append(containers, dto.InstanceContainerDiagnostics{
|
||||
Name: container.Name,
|
||||
Image: container.Image,
|
||||
Ready: container.Ready,
|
||||
RestartCount: container.RestartCount,
|
||||
State: container.State,
|
||||
Reason: container.Reason,
|
||||
Message: container.Message,
|
||||
})
|
||||
}
|
||||
conditions := make([]dto.InstanceConditionDiagnostics, 0, len(pod.Conditions))
|
||||
for _, condition := range pod.Conditions {
|
||||
conditions = append(conditions, dto.InstanceConditionDiagnostics{
|
||||
Type: condition.Type,
|
||||
Status: condition.Status,
|
||||
Reason: condition.Reason,
|
||||
Message: condition.Message,
|
||||
})
|
||||
}
|
||||
pods = append(pods, dto.InstancePodDiagnostics{
|
||||
Name: pod.Name,
|
||||
Namespace: pod.Namespace,
|
||||
Phase: pod.Phase,
|
||||
NodeName: pod.NodeName,
|
||||
PodIP: pod.PodIP,
|
||||
HostIP: pod.HostIP,
|
||||
RestartCount: pod.RestartCount,
|
||||
Containers: containers,
|
||||
Conditions: conditions,
|
||||
CreationTimestamp: formatTime(pod.CreationTimestamp),
|
||||
})
|
||||
}
|
||||
services := make([]dto.InstanceServiceDiagnostics, 0, len(diagnostics.Services))
|
||||
for _, svc := range diagnostics.Services {
|
||||
ports := make([]dto.InstanceEntryPortResponse, 0, len(svc.Ports))
|
||||
for _, port := range svc.Ports {
|
||||
ports = append(ports, dto.InstanceEntryPortResponse{
|
||||
Name: port.Name,
|
||||
Protocol: port.Protocol,
|
||||
Port: port.Port,
|
||||
TargetPort: port.TargetPort,
|
||||
NodePort: port.NodePort,
|
||||
})
|
||||
}
|
||||
services = append(services, dto.InstanceServiceDiagnostics{
|
||||
Name: svc.Name,
|
||||
Namespace: svc.Namespace,
|
||||
Type: svc.Type,
|
||||
ClusterIP: svc.ClusterIP,
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
events := make([]dto.InstanceEventDiagnostics, 0, len(diagnostics.Events))
|
||||
for _, event := range diagnostics.Events {
|
||||
events = append(events, dto.InstanceEventDiagnostics{
|
||||
Type: event.Type,
|
||||
Reason: event.Reason,
|
||||
Message: event.Message,
|
||||
InvolvedKind: event.InvolvedKind,
|
||||
InvolvedName: event.InvolvedName,
|
||||
Count: event.Count,
|
||||
FirstTimestamp: formatTime(event.FirstTimestamp),
|
||||
LastTimestamp: formatTime(event.LastTimestamp),
|
||||
})
|
||||
}
|
||||
logs := make([]dto.InstancePodLogResponse, 0, len(diagnostics.Logs))
|
||||
for _, logEntry := range diagnostics.Logs {
|
||||
logs = append(logs, dto.InstancePodLogResponse{
|
||||
Pod: logEntry.Pod,
|
||||
Container: logEntry.Container,
|
||||
TailLines: logEntry.TailLines,
|
||||
Log: logEntry.Log,
|
||||
Error: logEntry.Error,
|
||||
})
|
||||
}
|
||||
return &dto.InstanceDiagnosticsResponse{
|
||||
InstanceName: diagnostics.InstanceName,
|
||||
Namespace: diagnostics.Namespace,
|
||||
Pods: pods,
|
||||
Services: services,
|
||||
Events: events,
|
||||
Logs: logs,
|
||||
CollectedAt: formatTime(diagnostics.CollectedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse {
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
WorkspaceID: instance.WorkspaceID,
|
||||
OwnerID: instance.OwnerID,
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
Replicas: instance.Replicas,
|
||||
AllowedActions: []string{"view", "update", "delete"},
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if includeValues {
|
||||
response.Values = instance.Values
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func parseValuesYAML(valuesYAML string) (map[string]interface{}, error) {
|
||||
valuesYAML = strings.TrimSpace(valuesYAML)
|
||||
if valuesYAML == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
var decoded interface{}
|
||||
if err := yaml.Unmarshal([]byte(valuesYAML), &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalized, err := normalizeYAMLValue(decoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, ok := normalized.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("values YAML must be a mapping at the top level")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeYAMLValue(value interface{}) (interface{}, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
normalized := make(map[string]interface{}, len(typed))
|
||||
for key, child := range typed {
|
||||
normalizedChild, err := normalizeYAMLValue(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized[key] = normalizedChild
|
||||
}
|
||||
return normalized, nil
|
||||
case map[interface{}]interface{}:
|
||||
normalized := make(map[string]interface{}, len(typed))
|
||||
for key, child := range typed {
|
||||
keyString, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("values YAML contains non-string key %v", key)
|
||||
}
|
||||
normalizedChild, err := normalizeYAMLValue(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized[keyString] = normalizedChild
|
||||
}
|
||||
return normalized, nil
|
||||
case []interface{}:
|
||||
normalized := make([]interface{}, 0, len(typed))
|
||||
for _, child := range typed {
|
||||
normalizedChild, err := normalizeYAMLValue(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized = append(normalized, normalizedChild)
|
||||
}
|
||||
return normalized, nil
|
||||
default:
|
||||
return typed, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,9 +41,13 @@ func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// 创建实体
|
||||
registry := entity.NewRegistry("", "", req.Name, req.URL)
|
||||
registry := entity.NewRegistry(req.Name, req.URL)
|
||||
registry.Description = req.Description
|
||||
registry.Insecure = req.Insecure
|
||||
registry.Visibility = req.Visibility
|
||||
if req.GlobalShared || req.GlobalSharedAlt {
|
||||
registry.Visibility = "global_shared"
|
||||
}
|
||||
registry.SetCredentials(req.Username, req.Password)
|
||||
|
||||
// 调用领域服务
|
||||
@ -136,6 +140,12 @@ func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request)
|
||||
// 更新字段
|
||||
registry.Update(req.Name, req.URL, req.Description)
|
||||
registry.Insecure = req.Insecure
|
||||
if req.Visibility != "" {
|
||||
registry.Visibility = req.Visibility
|
||||
}
|
||||
if req.GlobalShared || req.GlobalSharedAlt {
|
||||
registry.Visibility = "global_shared"
|
||||
}
|
||||
if req.Username != "" || req.Password != "" {
|
||||
registry.SetCredentials(req.Username, req.Password)
|
||||
}
|
||||
|
||||
@ -1,340 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// StorageResolutionResponse 分层存储解析响应
|
||||
type StorageResolutionResponse struct {
|
||||
Storage *dto.StorageResponse `json:"storage,omitempty"`
|
||||
ValuesYAML string `json:"values_yaml,omitempty"`
|
||||
Source string `json:"source,omitempty"` // workspace, cluster, shared
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// StorageHandler Storage Backend Handler
|
||||
type StorageHandler struct {
|
||||
storageService *service.StorageService
|
||||
}
|
||||
|
||||
// NewStorageHandler 创建 Storage Handler
|
||||
func NewStorageHandler(storageService *service.StorageService) *StorageHandler {
|
||||
return &StorageHandler{
|
||||
storageService: storageService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateStorage 创建存储后端
|
||||
// @Summary 创建存储后端
|
||||
// @Description 新增存储后端配置(NFS/PV/hostPath)
|
||||
// @Tags Storage
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.CreateStorageRequest true "存储后端信息"
|
||||
// @Success 201 {object} dto.StorageResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /storage-backends [post]
|
||||
func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateStorageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
ownerID := r.Header.Get("X-User-ID")
|
||||
|
||||
// 构建配置
|
||||
storageType := entity.StorageType(req.Type)
|
||||
config := entity.StorageConfig{}
|
||||
|
||||
switch storageType {
|
||||
case entity.StorageTypeNFS:
|
||||
config.NFS = &entity.NFSConfig{
|
||||
Server: req.NFS.Server,
|
||||
Path: req.NFS.Path,
|
||||
}
|
||||
case entity.StorageTypePV:
|
||||
config.PV = &entity.PVConfig{
|
||||
StorageClassName: req.PV.StorageClassName,
|
||||
Capacity: req.PV.Capacity,
|
||||
AccessModes: req.PV.AccessModes,
|
||||
}
|
||||
case entity.StorageTypeHostPath:
|
||||
config.HostPath = &entity.HostPathConfig{
|
||||
Path: req.HostPath.Path,
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
storage, err := h.storageService.Create(
|
||||
r.Context(),
|
||||
workspaceID,
|
||||
ownerID,
|
||||
req.Name,
|
||||
storageType,
|
||||
config,
|
||||
req.Description,
|
||||
req.IsDefault,
|
||||
req.IsShared,
|
||||
req.ClusterID,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := toStorageResponse(storage)
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetStorage 获取存储后端详情
|
||||
// @Summary 获取存储后端
|
||||
// @Tags Storage
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param storage_id path string true "Storage ID"
|
||||
// @Success 200 {object} dto.StorageResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /storage-backends/{storage_id} [get]
|
||||
func (h *StorageHandler) GetStorage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
storageID := vars["storage_id"]
|
||||
|
||||
storage, err := h.storageService.GetByID(r.Context(), storageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Storage backend not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toStorageResponse(storage)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllStorage 获取所有存储后端
|
||||
// @Summary 列出所有存储后端
|
||||
// @Tags Storage
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.StorageResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /storage-backends [get]
|
||||
func (h *StorageHandler) GetAllStorage(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取 workspace_id(从 JWT)
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
role := r.Header.Get("X-User-Role")
|
||||
|
||||
var storages []*entity.StorageBackend
|
||||
var err error
|
||||
|
||||
// Admin 可以看到所有,其他用户只看自己 workspace + 共享的
|
||||
if role == "admin" {
|
||||
storages, err = h.storageService.List(r.Context())
|
||||
} else if workspaceID != "" {
|
||||
// 获取 workspace 的存储 + 共享存储
|
||||
workspaceStorages, _ := h.storageService.GetByWorkspace(r.Context(), workspaceID)
|
||||
sharedStorages, _ := h.storageService.GetShared(r.Context())
|
||||
|
||||
// 合并去重
|
||||
seen := make(map[string]bool)
|
||||
for _, s := range workspaceStorages {
|
||||
if !seen[s.ID] {
|
||||
storages = append(storages, s)
|
||||
seen[s.ID] = true
|
||||
}
|
||||
}
|
||||
for _, s := range sharedStorages {
|
||||
if !seen[s.ID] {
|
||||
storages = append(storages, s)
|
||||
seen[s.ID] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有 workspace 的用户只能看到共享存储
|
||||
storages, err = h.storageService.GetShared(r.Context())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list storage backends", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.StorageResponse, 0, len(storages))
|
||||
for _, storage := range storages {
|
||||
responses = append(responses, toStorageResponse(storage))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// UpdateStorage 更新存储后端
|
||||
// @Summary 更新存储后端
|
||||
// @Tags Storage
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param storage_id path string true "Storage ID"
|
||||
// @Param request body dto.UpdateStorageRequest true "更新内容"
|
||||
// @Success 200 {object} dto.StorageResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /storage-backends/{storage_id} [put]
|
||||
func (h *StorageHandler) UpdateStorage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
storageID := vars["storage_id"]
|
||||
|
||||
var req dto.UpdateStorageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 构建配置
|
||||
var storageType entity.StorageType
|
||||
if req.Type != "" {
|
||||
storageType = entity.StorageType(req.Type)
|
||||
}
|
||||
|
||||
config := entity.StorageConfig{}
|
||||
if storageType == entity.StorageTypeNFS && (req.NFS.Server != "" || req.NFS.Path != "") {
|
||||
config.NFS = &entity.NFSConfig{
|
||||
Server: req.NFS.Server,
|
||||
Path: req.NFS.Path,
|
||||
}
|
||||
} else if storageType == entity.StorageTypePV && req.PV.StorageClassName != "" {
|
||||
config.PV = &entity.PVConfig{
|
||||
StorageClassName: req.PV.StorageClassName,
|
||||
Capacity: req.PV.Capacity,
|
||||
AccessModes: req.PV.AccessModes,
|
||||
}
|
||||
} else if storageType == entity.StorageTypeHostPath && req.HostPath.Path != "" {
|
||||
config.HostPath = &entity.HostPathConfig{
|
||||
Path: req.HostPath.Path,
|
||||
}
|
||||
}
|
||||
|
||||
storage, err := h.storageService.Update(
|
||||
r.Context(),
|
||||
storageID,
|
||||
req.Name,
|
||||
req.Description,
|
||||
storageType,
|
||||
config,
|
||||
req.IsDefault,
|
||||
req.IsShared,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update storage backend", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toStorageResponse(storage)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteStorage 删除存储后端
|
||||
// @Summary 删除存储后端
|
||||
// @Tags Storage
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param storage_id path string true "Storage ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /storage-backends/{storage_id} [delete]
|
||||
func (h *StorageHandler) DeleteStorage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
storageID := vars["storage_id"]
|
||||
|
||||
if err := h.storageService.Delete(r.Context(), storageID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete storage backend", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ResolveStorage 预览分层存储解析结果
|
||||
// @Summary 预览分层存储解析结果
|
||||
// @Description 根据 cluster_id 和 workspace_id 解析出最终生效的存储配置
|
||||
// @Tags Storage
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id query string false "Cluster ID"
|
||||
// @Param workspace_id query string false "Workspace ID"
|
||||
// @Success 200 {object} StorageResolutionResponse
|
||||
// @Router /storage-backends/resolve [get]
|
||||
func (h *StorageHandler) ResolveStorage(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := r.URL.Query().Get("cluster_id")
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
|
||||
resolution, err := h.storageService.ResolveStorageConfig(r.Context(), clusterID, workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to resolve storage config", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if resolution == nil || resolution.Storage == nil {
|
||||
respondJSON(w, http.StatusOK, &StorageResolutionResponse{
|
||||
Message: "No default storage configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response := &StorageResolutionResponse{
|
||||
Storage: toStorageResponse(resolution.Storage),
|
||||
ValuesYAML: resolution.ValuesYAML,
|
||||
Source: resolution.Source,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// toStorageResponse 转换为响应 DTO
|
||||
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
|
||||
config := dto.StorageConfigDTO{}
|
||||
|
||||
if storage.Config.NFS != nil {
|
||||
config.NFS = &dto.NFSConfigDTO{
|
||||
Server: storage.Config.NFS.Server,
|
||||
Path: storage.Config.NFS.Path,
|
||||
}
|
||||
}
|
||||
if storage.Config.PV != nil {
|
||||
config.PV = &dto.PVConfigDTO{
|
||||
StorageClassName: storage.Config.PV.StorageClassName,
|
||||
Capacity: storage.Config.PV.Capacity,
|
||||
AccessModes: storage.Config.PV.AccessModes,
|
||||
}
|
||||
}
|
||||
if storage.Config.HostPath != nil {
|
||||
config.HostPath = &dto.HostPathConfigDTO{
|
||||
Path: storage.Config.HostPath.Path,
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.StorageResponse{
|
||||
ID: storage.ID,
|
||||
WorkspaceID: storage.WorkspaceID,
|
||||
ClusterID: storage.ClusterID,
|
||||
OwnerID: storage.OwnerID,
|
||||
Name: storage.Name,
|
||||
Type: string(storage.Type),
|
||||
Config: config,
|
||||
Description: storage.Description,
|
||||
IsDefault: storage.IsDefault,
|
||||
IsShared: storage.IsShared,
|
||||
CreatedAt: storage.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: storage.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// UserHandler 用户 HTTP 处理程序
|
||||
type UserHandler struct {
|
||||
authService *service.AuthService
|
||||
workspaceService *service.WorkspaceService
|
||||
}
|
||||
|
||||
// NewUserHandler 创建用户处理程序
|
||||
func NewUserHandler(authService *service.AuthService, workspaceService *service.WorkspaceService) *UserHandler {
|
||||
return &UserHandler{
|
||||
authService: authService,
|
||||
workspaceService: workspaceService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser 获取当前用户信息
|
||||
// @Summary 获取当前用户信息
|
||||
// @Description 获取当前登录用户的基本信息
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.UserResponseWithDTO
|
||||
// @Router /users/me [get]
|
||||
func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := GetUserIDFromRequest(r)
|
||||
if userID == "" {
|
||||
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 workspace 名称
|
||||
workspaceName := ""
|
||||
if user.WorkspaceID != "" {
|
||||
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
|
||||
if ws != nil {
|
||||
workspaceName = ws.Name
|
||||
}
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
|
||||
}
|
||||
|
||||
// ChangePassword 修改当前用户密码
|
||||
// @Summary 修改当前用户密码
|
||||
// @Description 修改当前登录用户的密码
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.ChangePasswordRequest true "修改密码请求"
|
||||
// @Success 200
|
||||
// @Router /users/me/password [put]
|
||||
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
userID := GetUserIDFromRequest(r)
|
||||
if userID == "" {
|
||||
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.ChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.ChangePassword(r.Context(), userID, req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "Password changed successfully", map[string]string{"message": "Password changed successfully"})
|
||||
}
|
||||
|
||||
// GetCurrentUserWorkspace 获取当前用户所属的 Workspace
|
||||
// @Summary 获取当前用户所属工作空间
|
||||
// @Description 获取当前用户所属工作空间的详细信息和配额
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.WorkspaceResponse
|
||||
// @Router /users/me/workspace [get]
|
||||
func (h *UserHandler) GetCurrentUserWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := GetUserIDFromRequest(r)
|
||||
if userID == "" {
|
||||
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Admin 没有 workspace
|
||||
if user.Role == entity.RoleAdmin {
|
||||
respondSuccess(w, "", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if user.WorkspaceID == "" {
|
||||
respondSuccess(w, "", nil)
|
||||
return
|
||||
}
|
||||
|
||||
workspace, err := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Workspace not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配额
|
||||
quotas, _ := h.workspaceService.GetQuotas(r.Context(), workspace.ID)
|
||||
|
||||
response := dto.WorkspaceResponse{
|
||||
Workspace: dto.WorkspaceDTOFromEntity(workspace),
|
||||
Quotas: dto.QuotaDTOsFromEntities(quotas),
|
||||
}
|
||||
|
||||
respondSuccess(w, "", response)
|
||||
}
|
||||
|
||||
// GetUserIDFromRequest 从请求中获取用户 ID
|
||||
func GetUserIDFromRequest(r *http.Request) string {
|
||||
// 尝试从 Header 获取(由中间件设置)
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID != "" {
|
||||
return userID
|
||||
}
|
||||
|
||||
// 尝试从 Context 获取(安全类型断言)
|
||||
if uid, ok := r.Context().Value("user_id").(string); ok {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -1,332 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// UserManagementHandler 用户管理 HTTP 处理程序
|
||||
type UserManagementHandler struct {
|
||||
userManagementService *service.UserManagementService
|
||||
authService *service.AuthService
|
||||
workspaceService *service.WorkspaceService
|
||||
}
|
||||
|
||||
// NewUserManagementHandler 创建用户管理处理程序
|
||||
func NewUserManagementHandler(
|
||||
userManagementService *service.UserManagementService,
|
||||
authService *service.AuthService,
|
||||
workspaceService *service.WorkspaceService,
|
||||
) *UserManagementHandler {
|
||||
return &UserManagementHandler{
|
||||
userManagementService: userManagementService,
|
||||
authService: authService,
|
||||
workspaceService: workspaceService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser 创建用户(Admin 操作)
|
||||
// @Summary 创建用户
|
||||
// @Description 创建新用户(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.CreateUserRequest true "创建用户请求"
|
||||
// @Success 200 {object} dto.UserResponseWithDTO
|
||||
// @Router /admin/users [post]
|
||||
func (h *UserManagementHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userManagementService.CreateUser(r.Context(), req.Username, req.Password, req.Email, req.Role, req.WorkspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 workspace 名称
|
||||
workspaceName := ""
|
||||
if user.WorkspaceID != "" {
|
||||
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
|
||||
if ws != nil {
|
||||
workspaceName = ws.Name
|
||||
}
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
|
||||
}
|
||||
|
||||
// GetUser 获取用户
|
||||
// @Summary 获取用户
|
||||
// @Description 获取指定用户信息(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Success 200 {object} dto.UserResponseWithDTO
|
||||
// @Router /admin/users/{user_id} [get]
|
||||
func (h *UserManagementHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
user, err := h.userManagementService.GetUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 workspace 名称
|
||||
workspaceName := ""
|
||||
if user.WorkspaceID != "" {
|
||||
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
|
||||
if ws != nil {
|
||||
workspaceName = ws.Name
|
||||
}
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
|
||||
}
|
||||
|
||||
// ListUsers 列出用户
|
||||
// @Summary 列出用户
|
||||
// @Description 获取所有用户列表(Admin 专用),可按 workspace_id 筛选
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id query string false "工作空间 ID"
|
||||
// @Success 200 {object} dto.UserListResponse
|
||||
// @Router /admin/users [get]
|
||||
func (h *UserManagementHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
|
||||
users, err := h.userManagementService.ListUsers(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有 workspace 名称
|
||||
workspaceNames := make(map[string]string)
|
||||
workspaces, _ := h.workspaceService.List(r.Context())
|
||||
for _, ws := range workspaces {
|
||||
workspaceNames[ws.ID] = ws.Name
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.UserListResponse{
|
||||
Users: dto.UserDTOsFromEntities(users, workspaceNames),
|
||||
Total: len(users),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户
|
||||
// @Summary 更新用户
|
||||
// @Description 更新用户信息(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Param request body dto.UpdateUserRequest true "更新用户请求"
|
||||
// @Success 200 {object} dto.UserResponseWithDTO
|
||||
// @Router /admin/users/{user_id} [put]
|
||||
func (h *UserManagementHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
user, err := h.userManagementService.GetUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
user.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.userManagementService.UpdateUser(r.Context(), user); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 workspace 名称
|
||||
workspaceName := ""
|
||||
if user.WorkspaceID != "" {
|
||||
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
|
||||
if ws != nil {
|
||||
workspaceName = ws.Name
|
||||
}
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
|
||||
}
|
||||
|
||||
// SetUserActive 启用/禁用用户
|
||||
// @Summary 启用/禁用用户
|
||||
// @Description 设置用户是否启用(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Param request body dto.SetUserActiveRequest true "启用状态"
|
||||
// @Success 200
|
||||
// @Router /admin/users/{user_id}/active [put]
|
||||
func (h *UserManagementHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
var req dto.SetUserActiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userManagementService.SetUserActive(r.Context(), userID, req.IsActive); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", nil)
|
||||
}
|
||||
|
||||
// ChangeUserWorkspace 分配用户到 Workspace
|
||||
// @Summary 分配用户到工作空间
|
||||
// @Description 将用户分配到指定工作空间(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Param request body dto.ChangeUserWorkspaceRequest true "工作空间分配请求"
|
||||
// @Success 200
|
||||
// @Router /admin/users/{user_id}/workspace [put]
|
||||
func (h *UserManagementHandler) ChangeUserWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
var req dto.ChangeUserWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userManagementService.ChangeUserWorkspace(r.Context(), userID, req.WorkspaceID); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", nil)
|
||||
}
|
||||
|
||||
// ResetPassword 重置用户密码(Admin 操作)
|
||||
// @Summary 重置用户密码
|
||||
// @Description 重置指定用户的密码(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Param request body dto.ResetPasswordRequest true "重置密码请求"
|
||||
// @Success 200
|
||||
// @Router /admin/users/{user_id}/password [put]
|
||||
func (h *UserManagementHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
var req dto.ResetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userManagementService.ResetPassword(r.Context(), userID, req.NewPassword); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", nil)
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户
|
||||
// @Summary 删除用户
|
||||
// @Description 删除指定用户(Admin 专用)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user_id path string true "用户 ID"
|
||||
// @Success 200
|
||||
// @Router /admin/users/{user_id} [delete]
|
||||
func (h *UserManagementHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
userID := vars["user_id"]
|
||||
|
||||
if err := h.userManagementService.DeleteUser(r.Context(), userID); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", nil)
|
||||
}
|
||||
|
||||
// requireAdmin 检查是否为 Admin
|
||||
func (h *UserManagementHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
if userRole != string(entity.RoleAdmin) {
|
||||
respondError(w, http.StatusForbidden, "Admin access required", "")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -3,7 +3,7 @@ package rest
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
)
|
||||
|
||||
@ -32,4 +32,3 @@ func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
|
||||
}
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
|
||||
@ -1,294 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// ValuesTemplateHandler Values Template Handler
|
||||
type ValuesTemplateHandler struct {
|
||||
valuesTemplateService *service.ValuesTemplateService
|
||||
}
|
||||
|
||||
// NewValuesTemplateHandler 创建 Values Template Handler
|
||||
func NewValuesTemplateHandler(valuesTemplateService *service.ValuesTemplateService) *ValuesTemplateHandler {
|
||||
return &ValuesTemplateHandler{
|
||||
valuesTemplateService: valuesTemplateService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateValuesTemplate 创建 Values 模板
|
||||
// @Summary 创建 Values 模板
|
||||
// @Description 新增 Values 模板配置(带版本管理)
|
||||
// @Tags Values Templates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.CreateValuesTemplateRequest true "Values 模板信息"
|
||||
// @Success 201 {object} dto.ValuesTemplateResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /values-templates [post]
|
||||
func (h *ValuesTemplateHandler) CreateValuesTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateValuesTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
ownerID := r.Header.Get("X-User-ID")
|
||||
|
||||
template, err := h.valuesTemplateService.Create(
|
||||
r.Context(),
|
||||
workspaceID,
|
||||
ownerID,
|
||||
req.ChartReferenceID,
|
||||
req.Name,
|
||||
req.Description,
|
||||
req.ValuesYAML,
|
||||
req.IsDefault,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create values template", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toValuesTemplateResponse(template)
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetValuesTemplate 获取 Values 模板详情
|
||||
// @Summary 获取 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param template_id path string true "Template ID"
|
||||
// @Success 200 {object} dto.ValuesTemplateResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /values-templates/{template_id} [get]
|
||||
func (h *ValuesTemplateHandler) GetValuesTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
templateID := vars["template_id"]
|
||||
|
||||
template, err := h.valuesTemplateService.GetByID(r.Context(), templateID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Values template not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toValuesTemplateResponse(template)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllValuesTemplates 获取所有 Values 模板
|
||||
// @Summary 列出所有 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.ValuesTemplateResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /values-templates [get]
|
||||
func (h *ValuesTemplateHandler) GetAllValuesTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := r.Header.Get("X-Workspace-ID")
|
||||
role := r.Header.Get("X-User-Role")
|
||||
|
||||
var templates []*dto.ValuesTemplateResponse
|
||||
|
||||
// Admin 可以看到所有,其他用户只看自己 workspace
|
||||
if role == "admin" {
|
||||
allTemplates, err := h.valuesTemplateService.List(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
|
||||
return
|
||||
}
|
||||
for _, t := range allTemplates {
|
||||
templates = append(templates, toValuesTemplateResponse(t))
|
||||
}
|
||||
} else if workspaceID != "" {
|
||||
workspaceTemplates, err := h.valuesTemplateService.GetByWorkspace(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
|
||||
return
|
||||
}
|
||||
for _, t := range workspaceTemplates {
|
||||
templates = append(templates, toValuesTemplateResponse(t))
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetValuesTemplatesByChartReference 获取 Chart Reference 的所有 Values 模板
|
||||
// @Summary 获取 Chart Reference 的 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Success 200 {array} dto.ValuesTemplateResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id}/values-templates [get]
|
||||
func (h *ValuesTemplateHandler) GetValuesTemplatesByChartReference(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
chartRefID := vars["chart_reference_id"]
|
||||
|
||||
templates, err := h.valuesTemplateService.GetByChartReference(r.Context(), chartRefID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.ValuesTemplateResponse, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
responses = append(responses, toValuesTemplateResponse(t))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// GetValuesTemplateHistory 获取模板的版本历史
|
||||
// @Summary 获取 Values 模板版本历史
|
||||
// @Tags Values Templates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Param name query string true "Template Name"
|
||||
// @Success 200 {array} dto.ValuesTemplateResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id}/values-templates/history [get]
|
||||
func (h *ValuesTemplateHandler) GetValuesTemplateHistory(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
chartRefID := vars["chart_reference_id"]
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
if name == "" {
|
||||
respondError(w, http.StatusBadRequest, "Template name is required", "")
|
||||
return
|
||||
}
|
||||
|
||||
templates, err := h.valuesTemplateService.GetHistory(r.Context(), chartRefID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get values template history", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.ValuesTemplateResponse, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
responses = append(responses, toValuesTemplateResponse(t))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// UpdateValuesTemplate 更新 Values 模板
|
||||
// @Summary 更新 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param template_id path string true "Template ID"
|
||||
// @Param request body dto.UpdateValuesTemplateRequest true "更新内容"
|
||||
// @Success 200 {object} dto.ValuesTemplateResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /values-templates/{template_id} [put]
|
||||
func (h *ValuesTemplateHandler) UpdateValuesTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
templateID := vars["template_id"]
|
||||
|
||||
var req dto.UpdateValuesTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
isDefault := false
|
||||
if req.IsDefault != nil {
|
||||
isDefault = *req.IsDefault
|
||||
}
|
||||
|
||||
template, err := h.valuesTemplateService.Update(
|
||||
r.Context(),
|
||||
templateID,
|
||||
req.Description,
|
||||
req.ValuesYAML,
|
||||
isDefault,
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update values template", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toValuesTemplateResponse(template)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteValuesTemplate 删除 Values 模板
|
||||
// @Summary 删除 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param template_id path string true "Template ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /values-templates/{template_id} [delete]
|
||||
func (h *ValuesTemplateHandler) DeleteValuesTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
templateID := vars["template_id"]
|
||||
|
||||
if err := h.valuesTemplateService.Delete(r.Context(), templateID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete values template", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RollbackValuesTemplate 回滚到指定版本
|
||||
// @Summary 回滚 Values 模板
|
||||
// @Tags Values Templates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param chart_reference_id path string true "Chart Reference ID"
|
||||
// @Param request body dto.RollbackValuesTemplateRequest true "回滚信息"
|
||||
// @Success 200 {object} dto.ValuesTemplateResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /chart-references/{chart_reference_id}/values-templates/rollback [post]
|
||||
func (h *ValuesTemplateHandler) RollbackValuesTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.RollbackValuesTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
template, err := h.valuesTemplateService.Rollback(r.Context(), req.TemplateID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to rollback values template", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := toValuesTemplateResponse(template)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// toValuesTemplateResponse 转换为响应 DTO
|
||||
func toValuesTemplateResponse(template *entity.ValuesTemplate) *dto.ValuesTemplateResponse {
|
||||
return &dto.ValuesTemplateResponse{
|
||||
ID: template.ID,
|
||||
WorkspaceID: template.WorkspaceID,
|
||||
OwnerID: template.OwnerID,
|
||||
ChartReferenceID: template.ChartReferenceID,
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
ValuesYAML: template.ValuesYAML,
|
||||
Version: template.Version,
|
||||
IsDefault: template.IsDefault,
|
||||
CreatedAt: template.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: template.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
@ -3,331 +3,163 @@ package rest
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
)
|
||||
|
||||
// WorkspaceHandler 工作空间 HTTP 处理程序
|
||||
type WorkspaceHandler struct {
|
||||
workspaceService *service.WorkspaceService
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
// NewWorkspaceHandler 创建工作空间处理程序
|
||||
func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService *service.AuthService) *WorkspaceHandler {
|
||||
return &WorkspaceHandler{
|
||||
workspaceService: workspaceService,
|
||||
authService: authService,
|
||||
}
|
||||
func NewWorkspaceHandler(workspaceService *service.WorkspaceService) *WorkspaceHandler {
|
||||
return &WorkspaceHandler{workspaceService: workspaceService}
|
||||
}
|
||||
|
||||
// CreateWorkspace 创建工作空间
|
||||
// @Summary 创建工作空间
|
||||
// @Description 创建新的工作空间(Admin 专用,支持 cluster_ids 和初始配额)
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.CreateWorkspaceRequest true "创建工作空间请求"
|
||||
// @Success 200 {object} dto.WorkspaceDTO
|
||||
// @Router /workspaces [post]
|
||||
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取创建者 ID
|
||||
userID := GetUserIDFromRequest(r)
|
||||
|
||||
// 准备配额
|
||||
quotas := make(map[entity.ResourceType]struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
})
|
||||
if req.CPU != nil {
|
||||
quotas[entity.ResourceCPU] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.CPU.HardLimit, req.CPU.SoftLimit}
|
||||
}
|
||||
if req.GPU != nil {
|
||||
quotas[entity.ResourceGPU] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.GPU.HardLimit, req.GPU.SoftLimit}
|
||||
}
|
||||
if req.GPUMemory != nil {
|
||||
quotas[entity.ResourceGPUMemory] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit}
|
||||
}
|
||||
|
||||
workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID, req.ClusterIDs, quotas)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
|
||||
type createWorkspaceRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// GetWorkspace 获取工作空间
|
||||
// @Summary 获取工作空间
|
||||
// @Description 获取指定工作空间的详细信息和配额
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id path string true "工作空间 ID"
|
||||
// @Success 200 {object} dto.WorkspaceResponse
|
||||
// @Router /workspaces/{workspace_id} [get]
|
||||
func (h *WorkspaceHandler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
workspaceID := vars["workspace_id"]
|
||||
|
||||
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Workspace not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查访问权限
|
||||
if !h.canAccessWorkspace(w, r, workspace.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配额
|
||||
quotas, _ := h.workspaceService.GetQuotas(r.Context(), workspace.ID)
|
||||
|
||||
response := dto.WorkspaceResponse{
|
||||
Workspace: dto.WorkspaceDTOFromEntity(workspace),
|
||||
Quotas: dto.QuotaDTOsFromEntities(quotas),
|
||||
}
|
||||
|
||||
respondSuccess(w, "", response)
|
||||
type workspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
K8sNamespace string `json:"k8sNamespace"`
|
||||
K8sSAName string `json:"k8sSaName"`
|
||||
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UpdateWorkspace 更新工作空间
|
||||
// @Summary 更新工作空间
|
||||
// @Description 更新工作空间信息(Admin 专用)
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id path string true "工作空间 ID"
|
||||
// @Param request body dto.UpdateWorkspaceRequest true "更新工作空间请求"
|
||||
// @Success 200 {object} dto.WorkspaceDTO
|
||||
// @Router /workspaces/{workspace_id} [put]
|
||||
func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
workspaceID := vars["workspace_id"]
|
||||
|
||||
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Workspace not found", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
workspace.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
workspace.Description = req.Description
|
||||
}
|
||||
if req.ClusterIDs != nil {
|
||||
workspace.ClusterIDs = req.ClusterIDs
|
||||
}
|
||||
|
||||
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
|
||||
type bindClusterRequest struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
}
|
||||
|
||||
// DeleteWorkspace 删除工作空间
|
||||
// @Summary 删除工作空间
|
||||
// @Description 删除指定工作空间(Admin 专用)
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id path string true "工作空间 ID"
|
||||
// @Success 200
|
||||
// @Router /workspaces/{workspace_id} [delete]
|
||||
func (h *WorkspaceHandler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
workspaceID := vars["workspace_id"]
|
||||
|
||||
if err := h.workspaceService.Delete(r.Context(), workspaceID); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", nil)
|
||||
type kubeconfigRequest struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
TTLSeconds int64 `json:"ttlSeconds"`
|
||||
}
|
||||
|
||||
// ListWorkspaces 列出所有工作空间
|
||||
// @Summary 列出所有工作空间
|
||||
// @Description 获取所有工作空间列表(Admin 专用)
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.WorkspaceListResponse
|
||||
// @Router /workspaces [get]
|
||||
func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := h.workspaceService.List(r.Context())
|
||||
workspaces, err := h.workspaceService.ListWorkspaces(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error(), "")
|
||||
respondServiceError(w, err, "Failed to list workspaces")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.WorkspaceListResponse{
|
||||
Workspaces: dto.WorkspaceDTOsFromEntities(workspaces),
|
||||
Total: len(workspaces),
|
||||
})
|
||||
response := make([]workspaceResponse, 0, len(workspaces))
|
||||
for _, workspace := range workspaces {
|
||||
response = append(response, toWorkspaceResponse(workspace))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetWorkspaceQuotas 获取工作空间配额
|
||||
// @Summary 获取工作空间配额
|
||||
// @Description 获取指定工作空间的资源配额
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id path string true "工作空间 ID"
|
||||
// @Success 200 {array} dto.QuotaDTO
|
||||
// @Router /workspaces/{workspace_id}/quotas [get]
|
||||
func (h *WorkspaceHandler) GetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
workspaceID := vars["workspace_id"]
|
||||
|
||||
// 检查访问权限
|
||||
if !h.canAccessWorkspace(w, r, workspaceID) {
|
||||
return
|
||||
}
|
||||
|
||||
quotas, err := h.workspaceService.GetQuotas(r.Context(), workspaceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error(), "")
|
||||
return
|
||||
}
|
||||
|
||||
respondSuccess(w, "", dto.QuotaDTOsFromEntities(quotas))
|
||||
}
|
||||
|
||||
// SetWorkspaceQuotas 设置工作空间配额
|
||||
// @Summary 设置工作空间配额
|
||||
// @Description 设置指定工作空间的 CPU/GPU/GPU Memory 配额(Admin 专用)
|
||||
// @Tags workspace
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param workspace_id path string true "工作空间 ID"
|
||||
// @Param request body dto.SetQuotasRequest true "配额设置请求"
|
||||
// @Success 200 {array} dto.QuotaDTO
|
||||
// @Router /workspaces/{workspace_id}/quotas [put]
|
||||
func (h *WorkspaceHandler) SetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查权限(Admin)
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
workspaceID := vars["workspace_id"]
|
||||
|
||||
var req dto.SetQuotasRequest
|
||||
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
var req createWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
workspace, err := h.workspaceService.CreateWorkspace(r.Context(), req.Name)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to create workspace")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, toWorkspaceResponse(workspace))
|
||||
}
|
||||
|
||||
quotas := make(map[entity.ResourceType]struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
func (h *WorkspaceHandler) InitClusterBinding(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := mux.Vars(r)["workspace_id"]
|
||||
var req bindClusterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
binding, err := h.workspaceService.EnsureClusterBinding(r.Context(), workspaceID, req.ClusterID)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to initialize workspace cluster binding")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, binding)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) IssueKubeconfig(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := mux.Vars(r)["workspace_id"]
|
||||
var req kubeconfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
kubeconfig, err := h.workspaceService.IssueKubeconfig(r.Context(), workspaceID, req.ClusterID, time.Duration(req.TTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to issue kubeconfig")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kubeconfig": kubeconfig.Kubeconfig,
|
||||
"expiresAt": kubeconfig.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
if req.CPU != nil {
|
||||
quotas[entity.ResourceCPU] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.CPU.HardLimit, req.CPU.SoftLimit}
|
||||
func (h *WorkspaceHandler) IssueCurrentKubeconfig(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := r.URL.Query().Get("clusterId")
|
||||
if clusterID == "" {
|
||||
clusterID = r.URL.Query().Get("cluster_id")
|
||||
}
|
||||
if req.GPU != nil {
|
||||
quotas[entity.ResourceGPU] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.GPU.HardLimit, req.GPU.SoftLimit}
|
||||
}
|
||||
if req.GPUMemory != nil {
|
||||
quotas[entity.ResourceGPUMemory] = struct {
|
||||
HardLimit float64
|
||||
SoftLimit float64
|
||||
}{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit}
|
||||
}
|
||||
|
||||
if err := h.workspaceService.SetQuotas(r.Context(), workspaceID, quotas); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||
kubeconfig, err := h.workspaceService.IssueCurrentKubeconfig(r.Context(), clusterID, 2*time.Hour)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to issue kubeconfig")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回更新后的配额
|
||||
updatedQuotas, _ := h.workspaceService.GetQuotas(r.Context(), workspaceID)
|
||||
respondSuccess(w, "", dto.QuotaDTOsFromEntities(updatedQuotas))
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.Header().Set("X-OCDP-Kubeconfig-Expires-At", kubeconfig.ExpiresAt.Format(time.RFC3339))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(kubeconfig.Kubeconfig))
|
||||
}
|
||||
|
||||
// requireAdmin 检查是否为 Admin
|
||||
func (h *WorkspaceHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
if userRole != string(entity.RoleAdmin) {
|
||||
respondError(w, http.StatusForbidden, "Admin access required", "")
|
||||
return false
|
||||
func (h *WorkspaceHandler) SuspendWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := mux.Vars(r)["workspace_id"]
|
||||
if err := h.workspaceService.SuspendWorkspace(r.Context(), workspaceID); err != nil {
|
||||
respondServiceError(w, err, "Failed to suspend workspace")
|
||||
return
|
||||
}
|
||||
return true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// canAccessWorkspace 检查是否可以访问工作空间
|
||||
func (h *WorkspaceHandler) canAccessWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
|
||||
userRole := r.Header.Get("X-User-Role")
|
||||
userWorkspaceID := r.Header.Get("X-Workspace-ID")
|
||||
|
||||
// Admin 可以访问所有
|
||||
if userRole == string(entity.RoleAdmin) {
|
||||
return true
|
||||
func toWorkspaceResponse(workspace *entity.Workspace) workspaceResponse {
|
||||
return workspaceResponse{
|
||||
ID: workspace.ID,
|
||||
Name: workspace.Name,
|
||||
Status: string(workspace.Status),
|
||||
K8sNamespace: workspace.K8sNamespace,
|
||||
K8sSAName: workspace.K8sSAName,
|
||||
DefaultClusterID: workspace.DefaultClusterID,
|
||||
QuotaCPU: workspace.QuotaCPU,
|
||||
QuotaMemory: workspace.QuotaMemory,
|
||||
QuotaGPU: workspace.QuotaGPU,
|
||||
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||
CreatedBy: workspace.CreatedBy,
|
||||
CreatedAt: workspace.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: workspace.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// 普通用户只能访问自己的 workspace
|
||||
if userWorkspaceID != workspaceID {
|
||||
respondError(w, http.StatusForbidden, "Access denied", "")
|
||||
return false
|
||||
func respondServiceError(w http.ResponseWriter, err error, fallback string) {
|
||||
switch err {
|
||||
case entity.ErrUnauthorized, authz.ErrUnauthenticated:
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
case entity.ErrForbidden, authz.ErrForbidden, entity.ErrUserInactive, entity.ErrWorkspaceSuspended:
|
||||
respondError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||
case entity.ErrClusterNotFound, entity.ErrRegistryNotFound, entity.ErrInstanceNotFound, entity.ErrWorkspaceNotFound:
|
||||
respondError(w, http.StatusNotFound, fallback, err.Error())
|
||||
default:
|
||||
respondError(w, http.StatusBadRequest, fallback, err.Error())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +96,36 @@ func (f *AdapterFactory) CreateInstanceRepository() (repository.InstanceReposito
|
||||
return postgres.NewInstanceRepository(f.db), nil
|
||||
}
|
||||
|
||||
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewWorkspaceRepositoryMock(), nil
|
||||
}
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewWorkspaceRepository(f.db), nil
|
||||
}
|
||||
|
||||
func (f *AdapterFactory) CreateWorkspaceClusterBindingRepository() (repository.WorkspaceClusterBindingRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewWorkspaceClusterBindingRepositoryMock(), nil
|
||||
}
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewWorkspaceClusterBindingRepository(f.db), nil
|
||||
}
|
||||
|
||||
func (f *AdapterFactory) CreateAuditLogRepository() (repository.AuditLogRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewAuditLogRepositoryMock(), nil
|
||||
}
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewAuditLogRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateOCIClient 创建 OCI 客户端
|
||||
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
|
||||
if f.mode == ModeMock {
|
||||
@ -127,67 +157,18 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
|
||||
return k8s.NewEntryClient()
|
||||
}
|
||||
|
||||
// CreateWorkspaceRepository 创建 Workspace 仓储
|
||||
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
|
||||
func (f *AdapterFactory) CreateDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||
if f.mode == ModeMock {
|
||||
return nil, fmt.Errorf("workspace repository mock not implemented")
|
||||
return k8s.NewMockDiagnosticsClient()
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewWorkspaceRepository(f.db), nil
|
||||
return k8s.NewDiagnosticsClient()
|
||||
}
|
||||
|
||||
// CreateQuotaRepository 创建 Quota 仓储
|
||||
// CreateStorageRepository 创建存储后端仓储
|
||||
func (f *AdapterFactory) CreateStorageRepository() (repository.StorageRepository, error) {
|
||||
func (f *AdapterFactory) CreateTenantKubeClient() repository.TenantKubeClient {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewStorageRepositoryMock(), nil
|
||||
return k8s.NewMockTenantClient()
|
||||
}
|
||||
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewStorageRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateQuotaRepository 创建配额仓储
|
||||
func (f *AdapterFactory) CreateQuotaRepository() (repository.QuotaRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return nil, fmt.Errorf("quota repository mock not implemented")
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewQuotaRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateChartReferenceRepository 创建 Chart 引用仓储
|
||||
func (f *AdapterFactory) CreateChartReferenceRepository() (repository.ChartReferenceRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return nil, fmt.Errorf("chart reference repository mock not implemented")
|
||||
}
|
||||
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewChartReferenceRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateValuesTemplateRepository 创建 Values 模板仓储
|
||||
func (f *AdapterFactory) CreateValuesTemplateRepository() (repository.ValuesTemplateRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return nil, fmt.Errorf("values template repository mock not implemented")
|
||||
}
|
||||
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewValuesTemplateRepository(f.db), nil
|
||||
return k8s.NewTenantClient()
|
||||
}
|
||||
|
||||
// CreateAllRepositories 一次性创建所有 Repositories
|
||||
@ -217,14 +198,14 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
||||
return nil, fmt.Errorf("failed to create workspace repository: %w", err)
|
||||
}
|
||||
|
||||
storageRepo, err := f.CreateStorageRepository()
|
||||
bindingRepo, err := f.CreateWorkspaceClusterBindingRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage repository: %w", err)
|
||||
return nil, fmt.Errorf("failed to create workspace cluster binding repository: %w", err)
|
||||
}
|
||||
|
||||
quotaRepo, err := f.CreateQuotaRepository()
|
||||
auditRepo, err := f.CreateAuditLogRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create quota repository: %w", err)
|
||||
return nil, fmt.Errorf("failed to create audit log repository: %w", err)
|
||||
}
|
||||
|
||||
ociClient, err := f.CreateOCIClient()
|
||||
@ -240,49 +221,41 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
||||
// 创建 Metrics client(依赖 clusterRepo)
|
||||
metricsClient := f.CreateMetricsClient(clusterRepo)
|
||||
entryClient := f.CreateEntryClient()
|
||||
|
||||
chartRefRepo, err := f.CreateChartReferenceRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create chart reference repository: %w", err)
|
||||
}
|
||||
|
||||
valuesTemplateRepo, err := f.CreateValuesTemplateRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create values template repository: %w", err)
|
||||
}
|
||||
diagnosticsClient := f.CreateDiagnosticsClient()
|
||||
tenantClient := f.CreateTenantKubeClient()
|
||||
|
||||
return &Repositories{
|
||||
UserRepo: userRepo,
|
||||
ClusterRepo: clusterRepo,
|
||||
RegistryRepo: registryRepo,
|
||||
InstanceRepo: instanceRepo,
|
||||
WorkspaceRepo: workspaceRepo,
|
||||
StorageRepo: storageRepo,
|
||||
ChartRefRepo: chartRefRepo,
|
||||
ValuesTemplateRepo: valuesTemplateRepo,
|
||||
QuotaRepo: quotaRepo,
|
||||
OCIClient: ociClient,
|
||||
HelmClient: helmClient,
|
||||
MetricsClient: metricsClient,
|
||||
EntryClient: entryClient,
|
||||
UserRepo: userRepo,
|
||||
WorkspaceRepo: workspaceRepo,
|
||||
BindingRepo: bindingRepo,
|
||||
AuditRepo: auditRepo,
|
||||
ClusterRepo: clusterRepo,
|
||||
RegistryRepo: registryRepo,
|
||||
InstanceRepo: instanceRepo,
|
||||
OCIClient: ociClient,
|
||||
HelmClient: helmClient,
|
||||
MetricsClient: metricsClient,
|
||||
EntryClient: entryClient,
|
||||
DiagnosticsClient: diagnosticsClient,
|
||||
TenantKubeClient: tenantClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Repositories 所有仓储的集合
|
||||
type Repositories struct {
|
||||
UserRepo repository.UserRepository
|
||||
ClusterRepo repository.ClusterRepository
|
||||
RegistryRepo repository.RegistryRepository
|
||||
InstanceRepo repository.InstanceRepository
|
||||
WorkspaceRepo repository.WorkspaceRepository
|
||||
StorageRepo repository.StorageRepository
|
||||
ChartRefRepo repository.ChartReferenceRepository
|
||||
ValuesTemplateRepo repository.ValuesTemplateRepository
|
||||
QuotaRepo repository.QuotaRepository
|
||||
OCIClient repository.OCIClient
|
||||
HelmClient repository.HelmClient
|
||||
MetricsClient repository.MetricsClient
|
||||
EntryClient repository.InstanceEntryClient
|
||||
UserRepo repository.UserRepository
|
||||
WorkspaceRepo repository.WorkspaceRepository
|
||||
BindingRepo repository.WorkspaceClusterBindingRepository
|
||||
AuditRepo repository.AuditLogRepository
|
||||
ClusterRepo repository.ClusterRepository
|
||||
RegistryRepo repository.RegistryRepository
|
||||
InstanceRepo repository.InstanceRepository
|
||||
OCIClient repository.OCIClient
|
||||
HelmClient repository.HelmClient
|
||||
MetricsClient repository.MetricsClient
|
||||
EntryClient repository.InstanceEntryClient
|
||||
DiagnosticsClient repository.InstanceDiagnosticsClient
|
||||
TenantKubeClient repository.TenantKubeClient
|
||||
}
|
||||
|
||||
// ensureDBConnection 确保数据库连接已建立
|
||||
|
||||
@ -194,3 +194,13 @@ func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster,
|
||||
return instance.Values, nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"replicaCount": 1,
|
||||
"image": map[string]interface{}{
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@ -22,6 +21,7 @@ import (
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
|
||||
// HelmClient 真实的 Helm 客户端实现
|
||||
@ -37,39 +37,45 @@ func NewHelmClient() repository.HelmClient {
|
||||
}
|
||||
|
||||
// getActionConfig 获取 Helm action configuration
|
||||
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, error) {
|
||||
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, func(), error) {
|
||||
actionConfig := new(action.Configuration)
|
||||
|
||||
// 创建临时 kubeconfig 文件
|
||||
kubeconfigContent := cluster.GetKubeConfig()
|
||||
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
|
||||
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// 使用 kubeconfig 初始化 action config
|
||||
if err := actionConfig.Init(
|
||||
&kubeconfigGetter{kubeconfigPath: kubeconfigPath},
|
||||
&kubeconfigGetter{kubeconfigPath: kubeconfigPath, namespace: namespace},
|
||||
namespace,
|
||||
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
|
||||
func(format string, v ...interface{}) {
|
||||
// Log function
|
||||
},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize action config: %w", err)
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to initialize action config: %w", err)
|
||||
}
|
||||
|
||||
return actionConfig, nil
|
||||
return actionConfig, cleanup, nil
|
||||
}
|
||||
|
||||
// kubeconfigGetter implements RESTClientGetter
|
||||
type kubeconfigGetter struct {
|
||||
kubeconfigPath string
|
||||
namespace string
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
|
||||
@ -96,42 +102,45 @@ func (k *kubeconfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
||||
overrides := &clientcmd.ConfigOverrides{}
|
||||
if k.namespace != "" {
|
||||
overrides.Context = clientcmdapi.Context{Namespace: k.namespace}
|
||||
}
|
||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
|
||||
&clientcmd.ConfigOverrides{},
|
||||
overrides,
|
||||
)
|
||||
}
|
||||
|
||||
// Install 安装 Helm Chart
|
||||
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
install := action.NewInstall(actionConfig)
|
||||
install.ReleaseName = instance.Name
|
||||
install.Namespace = instance.Namespace
|
||||
install.CreateNamespace = true
|
||||
install.Wait = true
|
||||
install.Timeout = 1 * time.Minute
|
||||
install.Timeout = helmOperationTimeout()
|
||||
|
||||
// 加载 Chart(从本地路径或 OCI registry)
|
||||
// 这里简化处理,假设 chart 已经被拉取到本地
|
||||
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||
|
||||
chart, err := loader.Load(chartPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load chart: %w", err)
|
||||
}
|
||||
|
||||
// 执行安装
|
||||
log.Printf("[helm-install] step=run instance=%s values=%v", instance.Name, instance.Values)
|
||||
t0 := time.Now()
|
||||
rel, err := install.Run(chart, instance.Values)
|
||||
log.Printf("[helm-install] step=runDone instance=%s elapsed=%v err=%v", instance.Name, time.Since(t0), err)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install release: %w", err)
|
||||
}
|
||||
log.Printf("[helm-install] step=done instance=%s revision=%d", instance.Name, rel.Version)
|
||||
|
||||
// 更新 revision(状态由调用方根据操作结果设置)
|
||||
instance.Revision = rel.Version
|
||||
@ -142,15 +151,17 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
|
||||
|
||||
// Upgrade 升级 Helm Release
|
||||
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
upgrade := action.NewUpgrade(actionConfig)
|
||||
upgrade.Namespace = instance.Namespace
|
||||
upgrade.ReuseValues = true
|
||||
upgrade.Wait = true
|
||||
upgrade.Timeout = 5 * time.Minute
|
||||
upgrade.Timeout = helmOperationTimeout()
|
||||
|
||||
// 加载 Chart
|
||||
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||
@ -175,14 +186,15 @@ func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, insta
|
||||
|
||||
// Uninstall 卸载 Helm Release
|
||||
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
uninstall := action.NewUninstall(actionConfig)
|
||||
uninstall.Wait = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
uninstall.Timeout = helmOperationTimeout()
|
||||
|
||||
_, err = uninstall.Run(releaseName)
|
||||
if err != nil {
|
||||
@ -197,15 +209,16 @@ func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, rel
|
||||
|
||||
// Rollback 回滚 Helm Release
|
||||
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
rollback := action.NewRollback(actionConfig)
|
||||
rollback.Version = revision
|
||||
rollback.Wait = true
|
||||
rollback.Timeout = 5 * time.Minute
|
||||
rollback.Timeout = helmOperationTimeout()
|
||||
|
||||
if err := rollback.Run(releaseName); err != nil {
|
||||
return fmt.Errorf("failed to rollback release: %w", err)
|
||||
@ -214,12 +227,25 @@ func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, rele
|
||||
return nil
|
||||
}
|
||||
|
||||
func helmOperationTimeout() time.Duration {
|
||||
raw := os.Getenv("HELM_OPERATION_TIMEOUT")
|
||||
if raw == "" {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
timeout, err := time.ParseDuration(raw)
|
||||
if err != nil || timeout <= 0 {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
// GetStatus 获取 Release 状态
|
||||
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
status := action.NewStatus(actionConfig)
|
||||
rel, err := status.Run(releaseName)
|
||||
@ -232,10 +258,11 @@ func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, rel
|
||||
|
||||
// GetHistory 获取 Release 历史
|
||||
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
history := action.NewHistory(actionConfig)
|
||||
history.Max = 256
|
||||
@ -262,10 +289,11 @@ func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, re
|
||||
|
||||
// List 列出集群中的所有 Releases
|
||||
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
list := action.NewList(actionConfig)
|
||||
if namespace == "" {
|
||||
@ -287,12 +315,14 @@ func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespac
|
||||
|
||||
// GetValues 获取 Release 的 values
|
||||
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
getValues := action.NewGetValues(actionConfig)
|
||||
getValues.AllValues = true
|
||||
values, err := getValues.Run(releaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get values: %w", err)
|
||||
@ -301,6 +331,21 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// GetChartDefaultValues 从 chart 包中读取默认 values
|
||||
func (h *HelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
|
||||
chart, err := loader.Load(chartPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load chart: %w", err)
|
||||
}
|
||||
vals := make(map[string]interface{})
|
||||
if chart.Values != nil {
|
||||
for k, v := range chart.Values {
|
||||
vals[k] = v
|
||||
}
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// convertReleaseToInstance 转换 Helm Release 为 Instance
|
||||
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
|
||||
return &entity.Instance{
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package real
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKubeconfigGetterOverridesNamespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
kubeconfigPath := filepath.Join(t.TempDir(), "kubeconfig")
|
||||
kubeconfig := `apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- cluster:
|
||||
server: https://127.0.0.1:6443
|
||||
name: test
|
||||
contexts:
|
||||
- context:
|
||||
cluster: test
|
||||
user: test
|
||||
name: test
|
||||
current-context: test
|
||||
users:
|
||||
- name: test
|
||||
user:
|
||||
token: test
|
||||
`
|
||||
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0600); err != nil {
|
||||
t.Fatalf("failed to write kubeconfig: %v", err)
|
||||
}
|
||||
getter := &kubeconfigGetter{
|
||||
kubeconfigPath: kubeconfigPath,
|
||||
namespace: "ocdp-u-alice",
|
||||
}
|
||||
|
||||
namespace, _, err := getter.ToRawKubeConfigLoader().Namespace()
|
||||
if err != nil {
|
||||
t.Fatalf("Namespace returned error: %v", err)
|
||||
}
|
||||
if namespace != "ocdp-u-alice" {
|
||||
t.Fatalf("expected namespace override %q, got %q", "ocdp-u-alice", namespace)
|
||||
}
|
||||
}
|
||||
374
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
374
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
@ -0,0 +1,374 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
type DiagnosticsClient struct{}
|
||||
|
||||
func NewDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||
return &DiagnosticsClient{}
|
||||
}
|
||||
|
||||
type MockDiagnosticsClient struct{}
|
||||
|
||||
func NewMockDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||
return &MockDiagnosticsClient{}
|
||||
}
|
||||
|
||||
func (*MockDiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||
return &entity.InstanceDiagnostics{
|
||||
InstanceName: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*MockDiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
|
||||
lines := make(chan string, 10)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
defer close(errs)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case lines <- "[mock] Streaming pod logs...":
|
||||
case lines <- "[mock] Container started successfully":
|
||||
case lines <- "[mock] Listening on :8080":
|
||||
}
|
||||
}()
|
||||
return lines, errs, nil
|
||||
}
|
||||
|
||||
func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||
clientset, err := diagnosticsClientset(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailLines <= 0 {
|
||||
tailLines = 200
|
||||
}
|
||||
if tailLines > 2000 {
|
||||
tailLines = 2000
|
||||
}
|
||||
|
||||
pods, err := listInstancePods(ctx, clientset, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := listInstanceServices(ctx, clientset, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := listInstanceEvents(ctx, clientset, instance, pods, services)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs := collectPodLogs(ctx, clientset, pods, tailLines)
|
||||
|
||||
return &entity.InstanceDiagnostics{
|
||||
InstanceName: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
Pods: convertPodsToDiagnostics(pods),
|
||||
Services: convertServicesToDiagnostics(services),
|
||||
Events: convertEventsToDiagnostics(events),
|
||||
Logs: logs,
|
||||
CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *DiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
|
||||
clientset, err := diagnosticsClientset(cluster)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if tailLines <= 0 {
|
||||
tailLines = 200
|
||||
}
|
||||
if tailLines > 2000 {
|
||||
tailLines = 2000
|
||||
}
|
||||
|
||||
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
|
||||
Container: containerName,
|
||||
Follow: true,
|
||||
TailLines: &tailLines,
|
||||
})
|
||||
|
||||
stream, err := req.Stream(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to open log stream for %s/%s: %w", podName, containerName, err)
|
||||
}
|
||||
|
||||
lines := make(chan string, 64)
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(lines)
|
||||
defer close(errs)
|
||||
defer func() { _ = stream.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(stream)
|
||||
// Allow long lines; Kubernetes log entries can exceed the default 64 KiB
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case lines <- line:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
select {
|
||||
case errs <- err:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return lines, errs, nil
|
||||
}
|
||||
|
||||
func diagnosticsClientset(cluster *entity.Cluster) (kubernetes.Interface, error) {
|
||||
config, err := restConfigFromCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create diagnostics kubernetes client: %w", err)
|
||||
}
|
||||
return clientset, nil
|
||||
}
|
||||
|
||||
func listInstancePods(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Pod, error) {
|
||||
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||
pods, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance pods: %w", err)
|
||||
}
|
||||
if len(pods.Items) > 0 {
|
||||
return pods.Items, nil
|
||||
}
|
||||
all, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespace pods: %w", err)
|
||||
}
|
||||
filtered := make([]corev1.Pod, 0)
|
||||
for _, pod := range all.Items {
|
||||
if resourceMatchesInstance(pod.ObjectMeta, instance) {
|
||||
filtered = append(filtered, pod)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func listInstanceServices(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Service, error) {
|
||||
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||
services, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance services: %w", err)
|
||||
}
|
||||
if len(services.Items) > 0 {
|
||||
return services.Items, nil
|
||||
}
|
||||
all, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespace services: %w", err)
|
||||
}
|
||||
filtered := make([]corev1.Service, 0)
|
||||
for _, svc := range all.Items {
|
||||
if resourceMatchesInstance(svc.ObjectMeta, instance) {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func listInstanceEvents(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance, pods []corev1.Pod, services []corev1.Service) ([]corev1.Event, error) {
|
||||
events, err := clientset.CoreV1().Events(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance events: %w", err)
|
||||
}
|
||||
names := map[string]bool{instance.Name: true}
|
||||
for _, pod := range pods {
|
||||
names[pod.Name] = true
|
||||
}
|
||||
for _, svc := range services {
|
||||
names[svc.Name] = true
|
||||
}
|
||||
filtered := make([]corev1.Event, 0)
|
||||
for _, event := range events.Items {
|
||||
if names[event.InvolvedObject.Name] || strings.Contains(event.Message, instance.Name) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].LastTimestamp.Time.After(filtered[j].LastTimestamp.Time)
|
||||
})
|
||||
if len(filtered) > 100 {
|
||||
filtered = filtered[:100]
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func collectPodLogs(ctx context.Context, clientset kubernetes.Interface, pods []corev1.Pod, tailLines int64) []entity.InstancePodLog {
|
||||
logs := make([]entity.InstancePodLog, 0)
|
||||
for _, pod := range pods {
|
||||
for _, container := range pod.Spec.Containers {
|
||||
item := entity.InstancePodLog{Pod: pod.Name, Container: container.Name, TailLines: tailLines}
|
||||
req := clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||
Container: container.Name,
|
||||
TailLines: &tailLines,
|
||||
})
|
||||
stream, err := req.Stream(ctx)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
logs = append(logs, item)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stream, 1<<20))
|
||||
_ = stream.Close()
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
} else {
|
||||
item.Log = string(data)
|
||||
}
|
||||
logs = append(logs, item)
|
||||
}
|
||||
}
|
||||
return logs
|
||||
}
|
||||
|
||||
func convertPodsToDiagnostics(pods []corev1.Pod) []entity.InstancePodDiagnostics {
|
||||
out := make([]entity.InstancePodDiagnostics, 0, len(pods))
|
||||
for _, pod := range pods {
|
||||
containers := make([]entity.InstanceContainerDiagnostics, 0, len(pod.Status.ContainerStatuses))
|
||||
var restarts int32
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
restarts += status.RestartCount
|
||||
containers = append(containers, entity.InstanceContainerDiagnostics{
|
||||
Name: status.Name,
|
||||
Image: status.Image,
|
||||
Ready: status.Ready,
|
||||
RestartCount: status.RestartCount,
|
||||
State: containerStateName(status.State),
|
||||
Reason: containerStateReason(status.State),
|
||||
Message: containerStateMessage(status.State),
|
||||
})
|
||||
}
|
||||
conditions := make([]entity.InstanceConditionDiagnostics, 0, len(pod.Status.Conditions))
|
||||
for _, condition := range pod.Status.Conditions {
|
||||
conditions = append(conditions, entity.InstanceConditionDiagnostics{
|
||||
Type: string(condition.Type),
|
||||
Status: string(condition.Status),
|
||||
Reason: condition.Reason,
|
||||
Message: condition.Message,
|
||||
})
|
||||
}
|
||||
out = append(out, entity.InstancePodDiagnostics{
|
||||
Name: pod.Name,
|
||||
Namespace: pod.Namespace,
|
||||
Phase: string(pod.Status.Phase),
|
||||
NodeName: pod.Spec.NodeName,
|
||||
PodIP: pod.Status.PodIP,
|
||||
HostIP: pod.Status.HostIP,
|
||||
RestartCount: restarts,
|
||||
Containers: containers,
|
||||
Conditions: conditions,
|
||||
CreationTimestamp: pod.CreationTimestamp.Time,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertServicesToDiagnostics(services []corev1.Service) []entity.InstanceServiceDiagnostics {
|
||||
out := make([]entity.InstanceServiceDiagnostics, 0, len(services))
|
||||
for _, svc := range services {
|
||||
entry := convertServiceToEntry(&svc)
|
||||
out = append(out, entity.InstanceServiceDiagnostics{
|
||||
Name: svc.Name,
|
||||
Namespace: svc.Namespace,
|
||||
Type: string(svc.Spec.Type),
|
||||
ClusterIP: svc.Spec.ClusterIP,
|
||||
Ports: entry.Ports,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertEventsToDiagnostics(events []corev1.Event) []entity.InstanceEventDiagnostics {
|
||||
out := make([]entity.InstanceEventDiagnostics, 0, len(events))
|
||||
for _, event := range events {
|
||||
out = append(out, entity.InstanceEventDiagnostics{
|
||||
Type: event.Type,
|
||||
Reason: event.Reason,
|
||||
Message: event.Message,
|
||||
InvolvedKind: event.InvolvedObject.Kind,
|
||||
InvolvedName: event.InvolvedObject.Name,
|
||||
Count: event.Count,
|
||||
FirstTimestamp: event.FirstTimestamp.Time,
|
||||
LastTimestamp: event.LastTimestamp.Time,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containerStateName(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Running != nil:
|
||||
return "running"
|
||||
case state.Waiting != nil:
|
||||
return "waiting"
|
||||
case state.Terminated != nil:
|
||||
return "terminated"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func containerStateReason(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Waiting != nil:
|
||||
return state.Waiting.Reason
|
||||
case state.Terminated != nil:
|
||||
return state.Terminated.Reason
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func containerStateMessage(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Waiting != nil:
|
||||
return state.Waiting.Message
|
||||
case state.Terminated != nil:
|
||||
return state.Terminated.Message
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
134
backend/internal/adapter/output/k8s/scale_client.go
Normal file
134
backend/internal/adapter/output/k8s/scale_client.go
Normal file
@ -0,0 +1,134 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// ScaleClient provides K8s-native workload scaling (bypasses Helm)
|
||||
type ScaleClient struct{}
|
||||
|
||||
// NewScaleClient creates a ScaleClient
|
||||
func NewScaleClient() *ScaleClient {
|
||||
return &ScaleClient{}
|
||||
}
|
||||
|
||||
// findDeployment searches for a deployment matching the release name using various label strategies.
|
||||
func (c *ScaleClient) findDeployment(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (*appsv1.Deployment, error) {
|
||||
labelQueries := []string{
|
||||
fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||
fmt.Sprintf("release=%s", releaseName),
|
||||
fmt.Sprintf("app=%s", releaseName),
|
||||
fmt.Sprintf("app.kubernetes.io/name=%s", releaseName),
|
||||
}
|
||||
|
||||
for _, query := range labelQueries {
|
||||
deployments, err := clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: query,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(deployments.Items) > 0 {
|
||||
return &deployments.Items[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: get by name directly
|
||||
dep, err := clientset.AppsV1().Deployments(namespace).Get(ctx, releaseName, metav1.GetOptions{})
|
||||
if err == nil && dep != nil {
|
||||
return dep, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetDeploymentReplicas returns the current replicas count for a deployment.
|
||||
func (c *ScaleClient) GetDeploymentReplicas(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string) (int32, error) {
|
||||
clientset, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
dep, err := c.findDeployment(ctx, clientset, namespace, releaseName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if dep != nil && dep.Spec.Replicas != nil {
|
||||
return *dep.Spec.Replicas, nil
|
||||
}
|
||||
|
||||
// Fallback to statefulsets
|
||||
return c.getStatefulSetReplicas(ctx, clientset, namespace, releaseName)
|
||||
}
|
||||
|
||||
func (c *ScaleClient) getStatefulSetReplicas(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (int32, error) {
|
||||
stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(stsList.Items) == 0 {
|
||||
return 0, nil // No replicable workload found
|
||||
}
|
||||
sts := stsList.Items[0]
|
||||
if sts.Spec.Replicas != nil {
|
||||
return *sts.Spec.Replicas, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// ScaleDeployment scales the K8s deployment directly (bypasses Helm).
|
||||
func (c *ScaleClient) ScaleDeployment(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string, replicas int32) error {
|
||||
clientset, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
dep, err := c.findDeployment(ctx, clientset, namespace, releaseName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dep != nil {
|
||||
dep.Spec.Replicas = &replicas
|
||||
_, err = clientset.AppsV1().Deployments(namespace).Update(ctx, dep, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scale deployment %s: %w", dep.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try StatefulSets
|
||||
stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||
})
|
||||
if err == nil && len(stsList.Items) > 0 {
|
||||
sts := stsList.Items[0]
|
||||
sts.Spec.Replicas = &replicas
|
||||
_, err = clientset.AppsV1().StatefulSets(namespace).Update(ctx, &sts, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scale statefulset %s: %w", sts.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("no deployment or statefulset found for release %s in namespace %s", releaseName, namespace)
|
||||
}
|
||||
|
||||
func (c *ScaleClient) clientsetForCluster(cluster *entity.Cluster) (*kubernetes.Clientset, error) {
|
||||
restConfig, err := restConfigFromCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rest config: %w", err)
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(restConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create clientset: %w", err)
|
||||
}
|
||||
return clientset, nil
|
||||
}
|
||||
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
@ -0,0 +1,388 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// TenantClient provisions namespace-scoped tenant Kubernetes resources.
|
||||
type TenantClient struct {
|
||||
clientset kubernetes.Interface
|
||||
}
|
||||
|
||||
// NewTenantClient creates a tenant provisioning client that builds Kubernetes
|
||||
// clients from the supplied cluster entity for each call.
|
||||
func NewTenantClient() repository.TenantKubeClient {
|
||||
return &TenantClient{}
|
||||
}
|
||||
|
||||
// NewTenantClientForClientset creates a tenant provisioning client for tests or
|
||||
// callers that already own a Kubernetes client.
|
||||
func NewTenantClientForClientset(clientset kubernetes.Interface) repository.TenantKubeClient {
|
||||
return &TenantClient{clientset: clientset}
|
||||
}
|
||||
|
||||
// EnsureTenant idempotently ensures Namespace, ServiceAccount, RoleBinding, and
|
||||
// ResourceQuota resources for the tenant binding.
|
||||
func (c *TenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
clientset, _, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureNamespace(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureServiceAccount(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureRoleBinding(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureResourceQuota(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueKubeconfig returns a short-lived kubeconfig backed by a Kubernetes
|
||||
// TokenRequest. The token exists only in the returned value and is never stored.
|
||||
func (c *TenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientset, restConfig, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cappedTTL := entity.TenantTokenTTL(ttl)
|
||||
expirationSeconds := int64(cappedTTL.Seconds())
|
||||
tokenRequest, err := clientset.CoreV1().
|
||||
ServiceAccounts(binding.Namespace).
|
||||
CreateToken(ctx, binding.ServiceAccountName, &authenticationv1.TokenRequest{
|
||||
Spec: authenticationv1.TokenRequestSpec{
|
||||
ExpirationSeconds: &expirationSeconds,
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request tenant service account token: %w", err)
|
||||
}
|
||||
if tokenRequest.Status.Token == "" {
|
||||
return nil, entity.ErrInvalidTenantKubeconfigToken
|
||||
}
|
||||
|
||||
expiresAt := tokenRequest.Status.ExpirationTimestamp.Time
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt = time.Now().Add(cappedTTL)
|
||||
}
|
||||
kubeconfig, err := buildTenantKubeconfig(cluster, restConfig, binding, tokenRequest.Status.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &entity.TenantKubeconfig{
|
||||
Kubeconfig: kubeconfig,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SuspendTenant revokes tenant API access by deleting only the RoleBinding.
|
||||
func (c *TenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
clientset, _, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = clientset.RbacV1().
|
||||
RoleBindings(binding.Namespace).
|
||||
Delete(ctx, binding.RoleBindingName, metav1.DeleteOptions{})
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete tenant role binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) clientsetForCluster(cluster *entity.Cluster) (kubernetes.Interface, *rest.Config, error) {
|
||||
if c.clientset != nil {
|
||||
config := &rest.Config{Host: "https://kubernetes.default.svc"}
|
||||
if cluster != nil {
|
||||
clusterConfig, err := restConfigFromCluster(cluster)
|
||||
if err == nil {
|
||||
config = clusterConfig
|
||||
}
|
||||
}
|
||||
return c.clientset, config, nil
|
||||
}
|
||||
|
||||
config, err := restConfigFromCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create tenant kubernetes client: %w", err)
|
||||
}
|
||||
return clientset, config, nil
|
||||
}
|
||||
|
||||
func restConfigFromCluster(cluster *entity.Cluster) (*rest.Config, error) {
|
||||
if cluster == nil {
|
||||
return nil, entity.ErrInvalidClusterHost
|
||||
}
|
||||
if looksLikeKubeconfig(cluster.CAData) {
|
||||
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.CAData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tenant kubeconfig: %w", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
if strings.TrimSpace(cluster.Host) == "" {
|
||||
return nil, entity.ErrInvalidClusterHost
|
||||
}
|
||||
return &rest.Config{
|
||||
Host: cluster.Host,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
CAData: decodePossiblyBase64(cluster.CAData),
|
||||
CertData: decodePossiblyBase64(cluster.CertData),
|
||||
KeyData: decodePossiblyBase64(cluster.KeyData),
|
||||
},
|
||||
BearerToken: cluster.Token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureNamespace(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
namespace := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant namespace: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
if _, updateErr := clientset.CoreV1().Namespaces().Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant namespace: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant namespace: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureServiceAccount(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
serviceAccount := &corev1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.ServiceAccountName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Create(ctx, serviceAccount, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant service account: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
if _, updateErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant service account: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant service account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureRoleBinding(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
roleBinding := desiredRoleBinding(binding)
|
||||
_, err := clientset.RbacV1().RoleBindings(binding.Namespace).Create(ctx, roleBinding, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant role binding: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
current.Subjects = roleBinding.Subjects
|
||||
current.RoleRef = roleBinding.RoleRef
|
||||
if _, updateErr := clientset.RbacV1().RoleBindings(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant role binding: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant role binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureResourceQuota(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.ResourceQuotaName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: binding.ResourceQuotaHard.DeepCopy(),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Create(ctx, resourceQuota, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant resource quota: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
current.Spec.Hard = binding.ResourceQuotaHard.DeepCopy()
|
||||
if _, updateErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant resource quota: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant resource quota: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func desiredRoleBinding(binding entity.TenantBinding) *rbacv1.RoleBinding {
|
||||
return &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.RoleBindingName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.ServiceAccountKind,
|
||||
Name: binding.ServiceAccountName,
|
||||
Namespace: binding.Namespace,
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Kind: "ClusterRole",
|
||||
Name: binding.ClusterRoleName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildTenantKubeconfig(cluster *entity.Cluster, restConfig *rest.Config, binding entity.TenantBinding, token string) (string, error) {
|
||||
host := ""
|
||||
var caData []byte
|
||||
if restConfig != nil {
|
||||
host = restConfig.Host
|
||||
caData = append([]byte{}, restConfig.CAData...)
|
||||
}
|
||||
if host == "" && cluster != nil {
|
||||
host = cluster.Host
|
||||
}
|
||||
if len(caData) == 0 && cluster != nil {
|
||||
caData = decodePossiblyBase64(cluster.CAData)
|
||||
}
|
||||
if host == "" {
|
||||
return "", entity.ErrInvalidClusterHost
|
||||
}
|
||||
|
||||
clusterName := "tenant-cluster"
|
||||
if cluster != nil && cluster.Name != "" {
|
||||
clusterName = cluster.Name
|
||||
}
|
||||
userName := binding.ServiceAccountName
|
||||
contextName := fmt.Sprintf("%s/%s", clusterName, binding.Namespace)
|
||||
config := clientcmdapi.NewConfig()
|
||||
config.Clusters[clusterName] = &clientcmdapi.Cluster{
|
||||
Server: host,
|
||||
CertificateAuthorityData: caData,
|
||||
}
|
||||
config.AuthInfos[userName] = &clientcmdapi.AuthInfo{
|
||||
Token: token,
|
||||
}
|
||||
config.Contexts[contextName] = &clientcmdapi.Context{
|
||||
Cluster: clusterName,
|
||||
AuthInfo: userName,
|
||||
Namespace: binding.Namespace,
|
||||
}
|
||||
config.CurrentContext = contextName
|
||||
|
||||
bytes, err := clientcmd.Write(*config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build tenant kubeconfig: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func mergeObjectMetadata(meta *metav1.ObjectMeta, labels, annotations map[string]string) {
|
||||
if len(labels) > 0 && meta.Labels == nil {
|
||||
meta.Labels = map[string]string{}
|
||||
}
|
||||
for key, value := range labels {
|
||||
meta.Labels[key] = value
|
||||
}
|
||||
if len(annotations) > 0 && meta.Annotations == nil {
|
||||
meta.Annotations = map[string]string{}
|
||||
}
|
||||
for key, value := range annotations {
|
||||
meta.Annotations[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func decodePossiblyBase64(value string) []byte {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func looksLikeKubeconfig(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return strings.HasPrefix(trimmed, "apiVersion:") || strings.HasPrefix(trimmed, "kind: Config")
|
||||
}
|
||||
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
k8stesting "k8s.io/client-go/testing"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
func TestTenantClientEnsureTenantCreatesResources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clientset := fake.NewSimpleClientset()
|
||||
client := NewTenantClientForClientset(clientset)
|
||||
binding := tenantBinding()
|
||||
|
||||
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
||||
t.Fatalf("EnsureTenant returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); err != nil {
|
||||
t.Fatalf("expected namespace: %v", err)
|
||||
}
|
||||
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
||||
t.Fatalf("expected service account: %v", err)
|
||||
}
|
||||
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected role binding: %v", err)
|
||||
}
|
||||
if roleBinding.RoleRef.Kind != "ClusterRole" || roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
||||
t.Fatalf("unexpected role ref: %#v", roleBinding.RoleRef)
|
||||
}
|
||||
if len(roleBinding.Subjects) != 1 || roleBinding.Subjects[0].Name != binding.ServiceAccountName {
|
||||
t.Fatalf("unexpected role binding subjects: %#v", roleBinding.Subjects)
|
||||
}
|
||||
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected resource quota: %v", err)
|
||||
}
|
||||
if quota.Spec.Hard.Cpu().String() != "2" {
|
||||
t.Fatalf("expected cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantClientEnsureTenantUpdatesExistingResources(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
binding := tenantBinding()
|
||||
clientset := fake.NewSimpleClientset(
|
||||
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace}},
|
||||
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
||||
&rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: binding.RoleBindingName, Namespace: binding.Namespace},
|
||||
RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "view"},
|
||||
},
|
||||
&corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace},
|
||||
Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("1"),
|
||||
}},
|
||||
},
|
||||
)
|
||||
client := NewTenantClientForClientset(clientset)
|
||||
|
||||
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
||||
t.Fatalf("EnsureTenant returned error: %v", err)
|
||||
}
|
||||
|
||||
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected updated role binding: %v", err)
|
||||
}
|
||||
if roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
||||
t.Fatalf("expected role ref %q, got %q", binding.ClusterRoleName, roleBinding.RoleRef.Name)
|
||||
}
|
||||
if roleBinding.Labels["ocdp.io/tenant"] != binding.Namespace {
|
||||
t.Fatalf("expected tenant label on updated role binding, got %#v", roleBinding.Labels)
|
||||
}
|
||||
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected updated quota: %v", err)
|
||||
}
|
||||
if quota.Spec.Hard.Cpu().String() != "2" {
|
||||
t.Fatalf("expected updated cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantClientSuspendTenantDeletesOnlyRoleBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
binding := tenantBinding()
|
||||
clientset := fake.NewSimpleClientset(
|
||||
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace}},
|
||||
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
||||
desiredRoleBinding(binding),
|
||||
)
|
||||
client := NewTenantClientForClientset(clientset)
|
||||
|
||||
if err := client.SuspendTenant(ctx, nil, binding); err != nil {
|
||||
t.Fatalf("SuspendTenant returned error: %v", err)
|
||||
}
|
||||
if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
||||
t.Fatalf("expected deleted role binding, got err %v", err)
|
||||
}
|
||||
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
||||
t.Fatalf("service account should remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantClientIssueKubeconfigCapsTokenTTL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
binding := tenantBinding()
|
||||
clientset := fake.NewSimpleClientset(&corev1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace},
|
||||
})
|
||||
var requestedExpirationSeconds int64
|
||||
expiresAt := time.Now().Add(entity.MaxTenantKubeconfigTTL).UTC()
|
||||
clientset.Fake.PrependReactor("create", "serviceaccounts", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||
if action.GetSubresource() != "token" {
|
||||
return false, nil, nil
|
||||
}
|
||||
createAction := action.(k8stesting.CreateAction)
|
||||
tokenRequest := createAction.GetObject().(*authenticationv1.TokenRequest)
|
||||
if tokenRequest.Spec.ExpirationSeconds != nil {
|
||||
requestedExpirationSeconds = *tokenRequest.Spec.ExpirationSeconds
|
||||
}
|
||||
return true, &authenticationv1.TokenRequest{
|
||||
Status: authenticationv1.TokenRequestStatus{
|
||||
Token: "short-lived-token",
|
||||
ExpirationTimestamp: metav1.NewTime(expiresAt),
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
client := NewTenantClientForClientset(clientset)
|
||||
|
||||
kubeconfig, err := client.IssueKubeconfig(ctx, &entity.Cluster{Name: "test", Host: "https://example.invalid"}, binding, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueKubeconfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if requestedExpirationSeconds != int64(entity.MaxTenantKubeconfigTTL.Seconds()) {
|
||||
t.Fatalf("expected capped ttl %d, got %d", int64(entity.MaxTenantKubeconfigTTL.Seconds()), requestedExpirationSeconds)
|
||||
}
|
||||
if !kubeconfig.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("expected expiration %s, got %s", expiresAt, kubeconfig.ExpiresAt)
|
||||
}
|
||||
if !strings.Contains(kubeconfig.Kubeconfig, "short-lived-token") {
|
||||
t.Fatal("expected kubeconfig to contain issued token")
|
||||
}
|
||||
if !strings.Contains(kubeconfig.Kubeconfig, "namespace: tenant-a") {
|
||||
t.Fatalf("expected kubeconfig namespace, got:\n%s", kubeconfig.Kubeconfig)
|
||||
}
|
||||
}
|
||||
|
||||
func tenantBinding() entity.TenantBinding {
|
||||
binding := entity.NewTenantBinding("tenant-a")
|
||||
binding.ResourceQuotaHard = corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("2"),
|
||||
corev1.ResourceMemory: resource.MustParse("4Gi"),
|
||||
}
|
||||
return binding
|
||||
}
|
||||
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
@ -0,0 +1,36 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
type MockTenantClient struct{}
|
||||
|
||||
func NewMockTenantClient() repository.TenantKubeClient {
|
||||
return &MockTenantClient{}
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
return binding.Validate()
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
if err := binding.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiresAt := time.Now().Add(entity.TenantTokenTTL(ttl))
|
||||
return &entity.TenantKubeconfig{
|
||||
Kubeconfig: fmt.Sprintf("apiVersion: v1\nkind: Config\nclusters:\n- name: %s\n cluster:\n server: %s\ncontexts:\n- name: %s\n context:\n cluster: %s\n namespace: %s\n user: %s\ncurrent-context: %s\nusers:\n- name: %s\n user:\n token: mock-ephemeral-token\n",
|
||||
cluster.Name, cluster.Host, binding.Namespace, cluster.Name, binding.Namespace, binding.ServiceAccountName, binding.Namespace, binding.ServiceAccountName),
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
return binding.Validate()
|
||||
}
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
@ -13,7 +13,7 @@ import (
|
||||
// OCIClientMock OCI Registry 客户端 Mock 实现
|
||||
type OCIClientMock struct {
|
||||
// Mock 数据存储
|
||||
repositories map[string][]string // registryID -> []repositoryName
|
||||
repositories map[string][]string // registryID -> []repositoryName
|
||||
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
|
||||
}
|
||||
|
||||
@ -23,10 +23,10 @@ func NewOCIClientMock() repository.OCIClient {
|
||||
repositories: make(map[string][]string),
|
||||
artifacts: make(map[string]map[string][]*entity.Artifact),
|
||||
}
|
||||
|
||||
|
||||
// 初始化一些测试数据
|
||||
mock.initMockData()
|
||||
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
@ -38,18 +38,18 @@ func (c *OCIClientMock) initMockData() {
|
||||
// initArtifactsForRegistry initializes mock artifacts for a given registry ID
|
||||
func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
c.artifacts[registryID] = make(map[string][]*entity.Artifact)
|
||||
|
||||
|
||||
// vllm-serve artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.1.0",
|
||||
Digest: "sha256:abc123def456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 12345678,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.1.0",
|
||||
Digest: "sha256:abc123def456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 12345678,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "vllm-serve",
|
||||
"org.opencontainers.image.version": "0.1.0",
|
||||
@ -57,14 +57,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.2.0",
|
||||
Digest: "sha256:xyz789uvw012",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 13456789,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.2.0",
|
||||
Digest: "sha256:xyz789uvw012",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 13456789,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "vllm-serve",
|
||||
"org.opencontainers.image.version": "0.2.0",
|
||||
@ -72,36 +72,36 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// nginx artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/nginx",
|
||||
Tag: "1.0.0",
|
||||
Digest: "sha256:nginx123456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 5678901,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/nginx",
|
||||
Tag: "1.0.0",
|
||||
Digest: "sha256:nginx123456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 5678901,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "nginx",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// redis artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/redis",
|
||||
Tag: "6.2.0",
|
||||
Digest: "sha256:redis789abc",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 8901234,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/redis",
|
||||
Tag: "6.2.0",
|
||||
Digest: "sha256:redis789abc",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 8901234,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "redis",
|
||||
"org.opencontainers.image.version": "6.2.0",
|
||||
@ -109,18 +109,18 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// alpine artifacts (Docker Image)
|
||||
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "3.18",
|
||||
Digest: "sha256:alpine123",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2345678,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "3.18",
|
||||
Digest: "sha256:alpine123",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2345678,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "alpine",
|
||||
"org.opencontainers.image.version": "3.18",
|
||||
@ -128,14 +128,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||
},
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "latest",
|
||||
Digest: "sha256:alpine456",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2456789,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "latest",
|
||||
Digest: "sha256:alpine456",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2456789,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "alpine",
|
||||
},
|
||||
@ -144,7 +144,7 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||
// Check if we have cached data for this registry
|
||||
repos, exists := c.repositories[registry.ID]
|
||||
if !exists {
|
||||
@ -156,10 +156,20 @@ func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.R
|
||||
"library/alpine",
|
||||
}
|
||||
c.repositories[registry.ID] = repos
|
||||
|
||||
|
||||
// Also initialize artifacts for this registry
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(artifactType), "chart") {
|
||||
chartRepos := make([]string, 0)
|
||||
for _, repo := range repos {
|
||||
artifacts, _ := c.ListArtifacts(ctx, registry, repo, "chart")
|
||||
if len(artifacts) > 0 {
|
||||
chartRepos = append(chartRepos, repo)
|
||||
}
|
||||
}
|
||||
return chartRepos, nil
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
@ -170,20 +180,20 @@ func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Regi
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
regArtifacts = c.artifacts[registry.ID]
|
||||
}
|
||||
|
||||
|
||||
artifacts, exists := regArtifacts[repository]
|
||||
if !exists {
|
||||
return []*entity.Artifact{}, nil
|
||||
}
|
||||
|
||||
|
||||
// 应用 mediaType 过滤
|
||||
if mediaTypeFilter == "" || mediaTypeFilter == "all" {
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
|
||||
filtered := make([]*entity.Artifact, 0)
|
||||
filter := strings.ToLower(strings.TrimSpace(mediaTypeFilter))
|
||||
|
||||
|
||||
for _, artifact := range artifacts {
|
||||
switch filter {
|
||||
case "chart":
|
||||
@ -200,7 +210,7 @@ func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Regi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
@ -211,19 +221,19 @@ func (c *OCIClientMock) GetArtifact(ctx context.Context, registry *entity.Regist
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
regArtifacts = c.artifacts[registry.ID]
|
||||
}
|
||||
|
||||
|
||||
artifacts, exists := regArtifacts[repository]
|
||||
if !exists {
|
||||
return nil, entity.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
|
||||
// 根据 tag 或 digest 查找
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.Tag == reference || artifact.Digest == reference {
|
||||
return artifact, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, entity.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
@ -232,11 +242,11 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !artifact.IsChart() {
|
||||
return "", fmt.Errorf("not a helm chart")
|
||||
}
|
||||
|
||||
|
||||
// 返回 Mock values schema
|
||||
mockSchema := `{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
@ -262,32 +272,15 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
|
||||
return mockSchema, nil
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||
func (c *OCIClientMock) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||
artifact, err := c.GetArtifact(ctx, registry, repository, reference)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !artifact.IsChart() {
|
||||
return "", fmt.Errorf("not a helm chart")
|
||||
}
|
||||
|
||||
// 返回 Mock values.yaml
|
||||
mockValues := `# Default values for the chart
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: nginx
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
resources: {}
|
||||
`
|
||||
return mockValues, nil
|
||||
return "replicaCount: 1\nimage:\n repository: nginx\n tag: latest\nservice:\n type: ClusterIP\n", nil
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
||||
@ -309,4 +302,3 @@ func (c *OCIClientMock) CheckHealth(ctx context.Context, registry *entity.Regist
|
||||
// Mock 实现,总是返回健康
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -7,11 +7,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
@ -26,6 +29,30 @@ type OCIClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type harborProject struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type harborRepository struct {
|
||||
Name string `json:"name"`
|
||||
ArtifactCount int `json:"artifact_count"`
|
||||
}
|
||||
|
||||
type harborTag struct {
|
||||
Name string `json:"name"`
|
||||
PushTime string `json:"push_time"`
|
||||
}
|
||||
|
||||
type harborArtifact struct {
|
||||
Digest string `json:"digest"`
|
||||
MediaType string `json:"media_type"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Size int64 `json:"size"`
|
||||
PushTime string `json:"push_time"`
|
||||
Tags []harborTag `json:"tags"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
|
||||
// NewOCIClient 创建真实的 OCI 客户端
|
||||
func NewOCIClient() repository.OCIClient {
|
||||
return &OCIClient{
|
||||
@ -44,26 +71,13 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
|
||||
return nil, fmt.Errorf("failed to create registry client: %w", err)
|
||||
}
|
||||
|
||||
// 设置认证 - 优先使用 registry 自己的凭证,否则使用 .env 中的默认凭证
|
||||
username := reg.Username
|
||||
password := reg.Password
|
||||
|
||||
// 如果没有提供凭证,尝试从环境变量加载
|
||||
if (username == "" || password == "") && strings.Contains(reg.URL, "harbor") {
|
||||
if envUser := os.Getenv("HARBOR_USERNAME"); envUser != "" {
|
||||
username = envUser
|
||||
}
|
||||
if envPass := os.Getenv("HARBOR_PASSWORD"); envPass != "" {
|
||||
password = envPass
|
||||
}
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
// 设置认证
|
||||
if reg.Username != "" && reg.Password != "" {
|
||||
registry.Client = &auth.Client{
|
||||
Client: c.httpClient,
|
||||
Credential: auth.StaticCredential(registryURL, auth.Credential{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Username: reg.Username,
|
||||
Password: reg.Password,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -74,154 +88,325 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
// 优先使用 OCI _catalog API,失败时回退到 Harbor REST API v2
|
||||
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||
repositories := make([]string, 0)
|
||||
|
||||
// 尝试 OCI _catalog API
|
||||
reg, err := c.getRegistry(registry)
|
||||
log.Printf("[DEBUG ListRepositories] registry=%s, getRegistry err=%v", registry.URL, err)
|
||||
if err == nil {
|
||||
err = reg.Repositories(ctx, "", func(repos []string) error {
|
||||
log.Printf("[DEBUG ListRepositories] OCI got repos batch: %d", len(repos))
|
||||
repositories = append(repositories, repos...)
|
||||
return nil
|
||||
})
|
||||
log.Printf("[DEBUG ListRepositories] OCI reg.Repositories returned: err=%v, total_repos=%d", err, len(repositories))
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG ListRepositories] post-OCI check: err=%v, repos_count=%d", err, len(repositories))
|
||||
|
||||
if err == nil && len(repositories) > 0 {
|
||||
log.Printf("[DEBUG ListRepositories] OCI success, returning %d repos", len(repositories))
|
||||
// ListRepositories 列出 Registry 中的 repositories.
|
||||
// Harbor registry 优先使用 Harbor v2.0 API,避免 robot 账号依赖 /v2/_catalog 全局权限。
|
||||
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||
repositories, harborErr := c.listHarborRepositories(ctx, registry, artifactType)
|
||||
if harborErr == nil {
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
// 回退: 使用 Harbor REST API v2
|
||||
log.Printf("[Harbor Fallback] OCI failed (err=%v, repos=%d), checking if Harbor...", err, len(repositories))
|
||||
log.Printf("[Harbor Fallback] registry.URL=%s, contains 'harbor'=%v", registry.URL, strings.Contains(registry.URL, "harbor"))
|
||||
|
||||
if strings.Contains(registry.URL, "harbor") {
|
||||
log.Printf("[Harbor Fallback] Yes, this is Harbor! Calling Harbor REST API...")
|
||||
repos, fallbackErr := c.listHarborRepositories(registry)
|
||||
log.Printf("[Harbor Fallback] Got %d repos, err=%v", len(repos), fallbackErr)
|
||||
if fallbackErr == nil && len(repos) > 0 {
|
||||
log.Printf("[Harbor Fallback] Returning %d repos from Harbor API", len(repos))
|
||||
return repos, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
return nil, fallbackErr
|
||||
repositories, catalogErr := c.listOCIRepositories(ctx, registry)
|
||||
if catalogErr != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories via Harbor API: %v; OCI catalog fallback also failed: %w", harborErr, catalogErr)
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(artifactType), "chart") {
|
||||
chartRepos := make([]string, 0)
|
||||
for _, repo := range repositories {
|
||||
artifacts, err := c.ListArtifacts(ctx, registry, repo, "chart")
|
||||
if err == nil && len(artifacts) > 0 {
|
||||
chartRepos = append(chartRepos, repo)
|
||||
}
|
||||
}
|
||||
return chartRepos, nil
|
||||
}
|
||||
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) listOCIRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repositories := make([]string, 0)
|
||||
|
||||
err = reg.Repositories(ctx, "", func(repos []string) error {
|
||||
repositories = append(repositories, repos...)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
// listHarborRepositories 使用 Harbor REST API v2 获取仓库列表
|
||||
func (c *OCIClient) listHarborRepositories(registry *entity.Registry) ([]string, error) {
|
||||
// 解析 Harbor URL 基础地址
|
||||
baseURL := registry.URL
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
baseURL = strings.TrimPrefix(baseURL, "https://")
|
||||
baseURL = strings.TrimPrefix(baseURL, "http://")
|
||||
harborHost := "https://" + baseURL
|
||||
|
||||
// 获取认证信息
|
||||
username := registry.Username
|
||||
password := registry.Password
|
||||
if username == "" || password == "" {
|
||||
username = os.Getenv("HARBOR_USERNAME")
|
||||
password = os.Getenv("HARBOR_PASSWORD")
|
||||
}
|
||||
|
||||
// 获取项目列表
|
||||
projectsURL := harborHost + "/api/v2.0/projects"
|
||||
req, err := http.NewRequest("GET", projectsURL, nil)
|
||||
func (c *OCIClient) listHarborRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||
projects, err := c.harborListProjects(ctx, registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to list projects: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var projects []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repositories := make([]string, 0)
|
||||
pageSize := 100
|
||||
repositorySet := make(map[string]struct{})
|
||||
chartOnly := strings.EqualFold(strings.TrimSpace(artifactType), "chart") || strings.TrimSpace(artifactType) == ""
|
||||
|
||||
for _, project := range projects {
|
||||
page := 1
|
||||
log.Printf("[listHarborRepositories] Processing project: %s", project.Name)
|
||||
for {
|
||||
reposURL := fmt.Sprintf("%s/api/v2.0/projects/%s/repositories?page=%d&page_size=%d",
|
||||
harborHost, project.Name, page, pageSize)
|
||||
req, err := http.NewRequest("GET", reposURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[listHarborRepositories] page %d: NewRequest error: %v", page, err)
|
||||
break
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
projectName := strings.TrimSpace(project.Name)
|
||||
if projectName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[listHarborRepositories] page %d: Do error: %v", page, err)
|
||||
break
|
||||
}
|
||||
repositories, err := c.harborListProjectRepositories(ctx, registry, projectName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
resp.Body.Close()
|
||||
log.Printf("[listHarborRepositories] page %d: HTTP %d, body: %s", page, resp.StatusCode, string(bodyBytes))
|
||||
break
|
||||
for _, harborRepo := range repositories {
|
||||
repoName := normalizeHarborRepositoryName(projectName, harborRepo.Name)
|
||||
if repoName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var repos []struct {
|
||||
Name string `json:"name"`
|
||||
if chartOnly {
|
||||
artifacts, err := c.listHarborArtifacts(ctx, registry, repoName, "chart")
|
||||
if err != nil || len(artifacts) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
resp.Body.Close()
|
||||
log.Printf("[listHarborRepositories] page %d: Decode error: %v", page, err)
|
||||
break
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
log.Printf("[listHarborRepositories] page %d: got %d repos", page, len(repos))
|
||||
if len(repos) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
repositories = append(repositories, repo.Name)
|
||||
}
|
||||
page++
|
||||
repositorySet[repoName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[listHarborRepositories] Total repos collected: %d", len(repositories))
|
||||
repositories := make([]string, 0, len(repositorySet))
|
||||
for repo := range repositorySet {
|
||||
repositories = append(repositories, repo)
|
||||
}
|
||||
sort.Strings(repositories)
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) harborListProjects(ctx context.Context, registry *entity.Registry) ([]harborProject, error) {
|
||||
var projects []harborProject
|
||||
if err := c.harborGetPaged(ctx, registry, "/api/v2.0/projects", url.Values{"member": []string{"true"}}, &projects); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) harborListProjectRepositories(ctx context.Context, registry *entity.Registry, projectName string) ([]harborRepository, error) {
|
||||
var repositories []harborRepository
|
||||
path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories"
|
||||
if err := c.harborGetPaged(ctx, registry, path, nil, &repositories); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) listHarborArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
projectName, repoName, ok := splitHarborRepository(repository)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("repository %q is not a Harbor project repository path", repository)
|
||||
}
|
||||
|
||||
var harborArtifacts []harborArtifact
|
||||
path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories/" + url.PathEscape(repoName) + "/artifacts"
|
||||
query := url.Values{
|
||||
"with_tag": []string{"true"},
|
||||
"with_label": []string{"false"},
|
||||
}
|
||||
if err := c.harborGetPaged(ctx, registry, path, query, &harborArtifacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artifacts := make([]*entity.Artifact, 0)
|
||||
for _, harborArtifact := range harborArtifacts {
|
||||
tags := harborArtifact.Tags
|
||||
if len(tags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if strings.TrimSpace(tag.Name) == "" {
|
||||
continue
|
||||
}
|
||||
artifact := &entity.Artifact{
|
||||
Repository: repository,
|
||||
Tag: tag.Name,
|
||||
Digest: harborArtifact.Digest,
|
||||
MediaType: harborArtifact.MediaType,
|
||||
ConfigType: harborArtifact.ArtifactType,
|
||||
Size: harborArtifact.Size,
|
||||
Annotations: harborArtifact.Annotations,
|
||||
CreatedAt: parseHarborTime(firstNonEmpty(tag.PushTime, harborArtifact.PushTime)),
|
||||
}
|
||||
if artifact.Annotations == nil {
|
||||
artifact.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
artifact.DetermineType()
|
||||
if isHarborChartArtifact(harborArtifact) {
|
||||
artifact.Type = entity.ArtifactTypeChart
|
||||
}
|
||||
|
||||
if c.shouldIncludeArtifact(artifact, mediaTypeFilter) {
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) harborGetPaged(ctx context.Context, registry *entity.Registry, path string, query url.Values, target interface{}) error {
|
||||
const pageSize = 100
|
||||
|
||||
accumulated := make([]json.RawMessage, 0)
|
||||
for page := 1; ; page++ {
|
||||
pageQuery := cloneValues(query)
|
||||
pageQuery.Set("page", fmt.Sprintf("%d", page))
|
||||
pageQuery.Set("page_size", fmt.Sprintf("%d", pageSize))
|
||||
|
||||
body, total, err := c.harborGet(ctx, registry, path, pageQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pageItems []json.RawMessage
|
||||
if err := json.Unmarshal(body, &pageItems); err != nil {
|
||||
return fmt.Errorf("failed to decode Harbor response for %s: %w", path, err)
|
||||
}
|
||||
accumulated = append(accumulated, pageItems...)
|
||||
|
||||
if len(pageItems) < pageSize || (total >= 0 && len(accumulated) >= total) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
combined, err := json.Marshal(accumulated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to combine Harbor pages: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(combined, target); err != nil {
|
||||
return fmt.Errorf("failed to decode Harbor pages: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) harborGet(ctx context.Context, registry *entity.Registry, path string, query url.Values) ([]byte, int, error) {
|
||||
baseURL, err := harborBaseURL(registry)
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(baseURL, "/") + path
|
||||
if len(query) > 0 {
|
||||
requestURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if registry.Username != "" || registry.Password != "" {
|
||||
req.SetBasicAuth(registry.Username, registry.Password)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, -1, fmt.Errorf("Harbor API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||
if readErr != nil {
|
||||
return nil, -1, fmt.Errorf("failed to read Harbor API response: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, -1, fmt.Errorf("Harbor API %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
total := -1
|
||||
if value := strings.TrimSpace(resp.Header.Get("X-Total-Count")); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
total = parsed
|
||||
}
|
||||
}
|
||||
return body, total, nil
|
||||
}
|
||||
|
||||
func harborBaseURL(registry *entity.Registry) (string, error) {
|
||||
rawURL := strings.TrimSpace(registry.URL)
|
||||
if rawURL == "" {
|
||||
return "", fmt.Errorf("registry URL is empty")
|
||||
}
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid registry URL %q: %w", registry.URL, err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid registry URL %q", registry.URL)
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host, nil
|
||||
}
|
||||
|
||||
func splitHarborRepository(repository string) (string, string, bool) {
|
||||
projectName, repoName, ok := strings.Cut(strings.Trim(repository, "/"), "/")
|
||||
if !ok || projectName == "" || repoName == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return projectName, repoName, true
|
||||
}
|
||||
|
||||
func normalizeHarborRepositoryName(projectName, repositoryName string) string {
|
||||
repositoryName = strings.Trim(repositoryName, "/")
|
||||
if repositoryName == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(repositoryName, projectName+"/") {
|
||||
return repositoryName
|
||||
}
|
||||
return projectName + "/" + repositoryName
|
||||
}
|
||||
|
||||
func isHarborChartArtifact(artifact harborArtifact) bool {
|
||||
typeInfo := strings.ToLower(strings.TrimSpace(artifact.ArtifactType + " " + artifact.MediaType))
|
||||
return strings.Contains(typeInfo, "chart") || strings.Contains(typeInfo, "helm")
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
cloned := make(url.Values)
|
||||
for key, items := range values {
|
||||
cloned[key] = append([]string(nil), items...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseHarborTime(value string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
if artifacts, err := c.listHarborArtifacts(ctx, registry, repository, mediaTypeFilter); err == nil {
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -508,8 +693,19 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
|
||||
return "", entity.ErrValuesSchemaNotFound
|
||||
}
|
||||
|
||||
// GetValues 获取 Helm Chart 的 values.yaml
|
||||
func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||
// GetValuesYAML 获取 Helm Chart 包内原始 values.yaml
|
||||
func (c *OCIClient) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||
data, err := c.readChartFile(ctx, registry, repository, reference, "values.yaml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(data) == "" {
|
||||
return "", entity.ErrArtifactNotFound
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry, repository, reference, filename string) (string, error) {
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -520,7 +716,6 @@ func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, re
|
||||
return "", fmt.Errorf("failed to get repository: %w", err)
|
||||
}
|
||||
|
||||
// 解析 reference (tag 或 digest)
|
||||
desc, err := repo.Resolve(ctx, reference)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve artifact: %w", err)
|
||||
@ -542,7 +737,6 @@ func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, re
|
||||
return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
|
||||
}
|
||||
|
||||
// 查找 Helm Chart layer(tar+gzip 包含 chart 内容)并从中读取 values.yaml
|
||||
var chartLayer *ocispec.Descriptor
|
||||
for i := range manifest.Layers {
|
||||
layer := manifest.Layers[i]
|
||||
@ -552,11 +746,9 @@ func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, re
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chartLayer == nil {
|
||||
return "", entity.ErrValuesNotFound
|
||||
return "", fmt.Errorf("helm chart layer not found in manifest")
|
||||
}
|
||||
|
||||
if chartLayer.Digest == "" {
|
||||
return "", fmt.Errorf("chart layer digest is empty")
|
||||
}
|
||||
@ -577,6 +769,8 @@ func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, re
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
bestDepth := int(^uint(0) >> 1)
|
||||
var bestData []byte
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
@ -585,26 +779,25 @@ func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, re
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read chart archive: %w", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// 查找 values.yaml 文件(可能在 chart 根目录或子目录中)
|
||||
// 通常路径格式为: {chart-name}/values.yaml
|
||||
if strings.HasSuffix(header.Name, "values.yaml") {
|
||||
if strings.HasSuffix(header.Name, filename) {
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read values.yaml: %w", err)
|
||||
return "", fmt.Errorf("failed to read %s: %w", filename, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", entity.ErrValuesNotFound
|
||||
depth := strings.Count(strings.Trim(header.Name, "/"), "/")
|
||||
if depth < bestDepth {
|
||||
bestDepth = depth
|
||||
bestData = data
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", entity.ErrValuesNotFound
|
||||
if len(bestData) > 0 {
|
||||
return string(bestData), nil
|
||||
}
|
||||
return "", fmt.Errorf("%s not found in chart", filename)
|
||||
}
|
||||
|
||||
// PullArtifact 下载 artifact 到本地
|
||||
|
||||
@ -3,7 +3,7 @@ package mock
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
@ -27,21 +27,21 @@ func NewClusterRepositoryMock(encryptor crypto.Encryptor) repository.ClusterRepo
|
||||
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
// 检查名称是否已存在
|
||||
for _, c := range r.clusters {
|
||||
if c.Name == cluster.Name {
|
||||
return entity.ErrClusterExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Mock 模式:如果没有提供认证信息,自动填充默认的 Mock 证书
|
||||
if (cluster.CertData == "" || cluster.KeyData == "") && cluster.Token == "" {
|
||||
cluster.CAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="
|
||||
cluster.CertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
|
||||
cluster.KeyData = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t"
|
||||
}
|
||||
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedCluster := r.encryptCluster(cluster)
|
||||
r.clusters[cluster.ID] = encryptedCluster
|
||||
@ -51,12 +51,12 @@ func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Clus
|
||||
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
cluster, exists := r.clusters[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptCluster(cluster), nil
|
||||
}
|
||||
@ -64,25 +64,25 @@ func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity
|
||||
func (r *ClusterRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
for _, cluster := range r.clusters {
|
||||
if cluster.Name == name {
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptCluster(cluster), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.clusters[cluster.ID]; !exists {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedCluster := r.encryptCluster(cluster)
|
||||
r.clusters[cluster.ID] = encryptedCluster
|
||||
@ -92,11 +92,11 @@ func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Clus
|
||||
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.clusters[id]; !exists {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
|
||||
delete(r.clusters, id)
|
||||
return nil
|
||||
}
|
||||
@ -114,38 +114,10 @@ func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, er
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
clusters := make([]*entity.Cluster, 0)
|
||||
for _, cluster := range r.clusters {
|
||||
if cluster.WorkspaceID == workspaceID {
|
||||
clusters = append(clusters, r.decryptCluster(cluster))
|
||||
}
|
||||
}
|
||||
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
clusters := make([]*entity.Cluster, 0)
|
||||
for _, cluster := range r.clusters {
|
||||
if cluster.IsShared {
|
||||
clusters = append(clusters, r.decryptCluster(cluster))
|
||||
}
|
||||
}
|
||||
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
// encryptCluster 加密 Cluster 的敏感数据
|
||||
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||
encrypted := *cluster // 复制
|
||||
|
||||
|
||||
// 加密证书数据
|
||||
if cluster.CAData != "" && !crypto.IsEncrypted(cluster.CAData) {
|
||||
if encryptedData, err := r.encryptor.Encrypt(cluster.CAData); err == nil {
|
||||
@ -167,14 +139,14 @@ func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.
|
||||
encrypted.Token = encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return &encrypted
|
||||
}
|
||||
|
||||
// decryptCluster 解密 Cluster 的敏感数据
|
||||
func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||
decrypted := *cluster // 复制
|
||||
|
||||
|
||||
// 解密证书数据
|
||||
if cluster.CAData != "" && crypto.IsEncrypted(cluster.CAData) {
|
||||
if decryptedData, err := r.encryptor.Decrypt(cluster.CAData); err == nil {
|
||||
@ -196,7 +168,6 @@ func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.
|
||||
decrypted.Token = decryptedData
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return &decrypted
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ package mock
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
@ -24,14 +24,14 @@ func NewInstanceRepositoryMock() repository.InstanceRepository {
|
||||
func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.Instance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
// 检查同一集群中名称是否已存在
|
||||
for _, inst := range r.instances {
|
||||
if inst.ClusterID == instance.ClusterID && inst.Name == instance.Name {
|
||||
return entity.ErrInstanceExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
r.instances[instance.ID] = instance
|
||||
return nil
|
||||
}
|
||||
@ -39,36 +39,36 @@ func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.In
|
||||
func (r *InstanceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
instance, exists := r.instances[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
for _, instance := range r.instances {
|
||||
if instance.ClusterID == clusterID && instance.Name == name {
|
||||
return instance, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.Instance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.instances[instance.ID]; !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
|
||||
r.instances[instance.ID] = instance
|
||||
return nil
|
||||
}
|
||||
@ -76,11 +76,11 @@ func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.In
|
||||
func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.instances[id]; !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
|
||||
delete(r.instances, id)
|
||||
return nil
|
||||
}
|
||||
@ -88,14 +88,14 @@ func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for _, instance := range r.instances {
|
||||
if instance.ClusterID == clusterID {
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
@ -110,18 +110,3 @@ func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance,
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for _, instance := range r.instances {
|
||||
if instance.WorkspaceID == workspaceID {
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ package mock
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
@ -27,14 +27,14 @@ func NewRegistryRepositoryMock(encryptor crypto.Encryptor) repository.RegistryRe
|
||||
func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Registry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
// 检查名称是否已存在
|
||||
for _, reg := range r.registries {
|
||||
if reg.Name == registry.Name {
|
||||
return entity.ErrRegistryExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedRegistry := r.encryptRegistry(registry)
|
||||
r.registries[registry.ID] = encryptedRegistry
|
||||
@ -44,12 +44,12 @@ func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Re
|
||||
func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
registry, exists := r.registries[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptRegistry(registry), nil
|
||||
}
|
||||
@ -57,25 +57,25 @@ func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entit
|
||||
func (r *RegistryRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
for _, registry := range r.registries {
|
||||
if registry.Name == name {
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptRegistry(registry), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Registry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.registries[registry.ID]; !exists {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedRegistry := r.encryptRegistry(registry)
|
||||
r.registries[registry.ID] = encryptedRegistry
|
||||
@ -85,11 +85,11 @@ func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Re
|
||||
func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.registries[id]; !exists {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
|
||||
delete(r.registries, id)
|
||||
return nil
|
||||
}
|
||||
@ -97,41 +97,40 @@ func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
func (r *RegistryRepositoryMock) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
registries := make([]*entity.Registry, 0, len(r.registries))
|
||||
for _, registry := range r.registries {
|
||||
// 解密敏感数据后返回
|
||||
registries = append(registries, r.decryptRegistry(registry))
|
||||
}
|
||||
|
||||
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
// encryptRegistry 加密 Registry 的敏感数据
|
||||
func (r *RegistryRepositoryMock) encryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||
encrypted := *registry // 复制
|
||||
|
||||
|
||||
// 加密密码
|
||||
if registry.Password != "" && !crypto.IsEncrypted(registry.Password) {
|
||||
if encryptedPassword, err := r.encryptor.Encrypt(registry.Password); err == nil {
|
||||
encrypted.Password = encryptedPassword
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return &encrypted
|
||||
}
|
||||
|
||||
// decryptRegistry 解密 Registry 的敏感数据
|
||||
func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||
decrypted := *registry // 复制
|
||||
|
||||
|
||||
// 解密密码
|
||||
if registry.Password != "" && crypto.IsEncrypted(registry.Password) {
|
||||
if decryptedPassword, err := r.encryptor.Decrypt(registry.Password); err == nil {
|
||||
decrypted.Password = decryptedPassword
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return &decrypted
|
||||
}
|
||||
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// StorageRepositoryMock Storage 仓储 Mock
|
||||
type StorageRepositoryMock struct {
|
||||
storages map[string]*entity.StorageBackend
|
||||
}
|
||||
|
||||
// NewStorageRepositoryMock 创建 Mock
|
||||
func NewStorageRepositoryMock() *StorageRepositoryMock {
|
||||
return &StorageRepositoryMock{
|
||||
storages: make(map[string]*entity.StorageBackend),
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建存储
|
||||
func (r *StorageRepositoryMock) Create(ctx context.Context, storage *entity.StorageBackend) error {
|
||||
r.storages[storage.ID] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 获取存储
|
||||
func (r *StorageRepositoryMock) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
|
||||
if s, ok := r.storages[id]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, entity.ErrStorageNotFound
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取工作空间的存储
|
||||
func (r *StorageRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
|
||||
var result []*entity.StorageBackend
|
||||
for _, s := range r.storages {
|
||||
if s.WorkspaceID == workspaceID {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByName 按名称获取
|
||||
func (r *StorageRepositoryMock) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
|
||||
for _, s := range r.storages {
|
||||
if s.WorkspaceID == workspaceID && s.Name == name {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, entity.ErrStorageNotFound
|
||||
}
|
||||
|
||||
// Update 更新存储
|
||||
func (r *StorageRepositoryMock) Update(ctx context.Context, storage *entity.StorageBackend) error {
|
||||
r.storages[storage.ID] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除存储
|
||||
func (r *StorageRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
delete(r.storages, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetShared 获取所有共享存储后端
|
||||
func (r *StorageRepositoryMock) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||
var result []*entity.StorageBackend
|
||||
for _, s := range r.storages {
|
||||
if s.IsShared {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDefault 获取 workspace 的默认存储后端
|
||||
func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
|
||||
for _, s := range r.storages {
|
||||
if s.WorkspaceID == workspaceID && s.IsDefault {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetByCluster 获取 cluster 关联的存储后端
|
||||
func (r *StorageRepositoryMock) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) {
|
||||
var result []*entity.StorageBackend
|
||||
for _, s := range r.storages {
|
||||
if s.ClusterID == clusterID {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDefaultByCluster 获取 cluster 的默认存储后端
|
||||
func (r *StorageRepositoryMock) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) {
|
||||
for _, s := range r.storages {
|
||||
if s.ClusterID == clusterID && s.IsDefault {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// List 列出所有存储(管理员用)
|
||||
func (r *StorageRepositoryMock) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||
var result []*entity.StorageBackend
|
||||
for _, s := range r.storages {
|
||||
result = append(result, s)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@ -3,7 +3,7 @@ package mock
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
@ -24,14 +24,14 @@ func NewUserRepositoryMock() repository.UserRepository {
|
||||
func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
// 检查是否已存在
|
||||
for _, u := range r.users {
|
||||
if u.Username == user.Username {
|
||||
return entity.ErrUserExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
r.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
@ -39,36 +39,36 @@ func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) erro
|
||||
func (r *UserRepositoryMock) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
|
||||
for _, user := range r.users {
|
||||
if user.Username == username {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.users[user.ID]; !exists {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
|
||||
r.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
@ -76,11 +76,11 @@ func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) erro
|
||||
func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
if _, exists := r.users[id]; !exists {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
|
||||
delete(r.users, id)
|
||||
return nil
|
||||
}
|
||||
@ -96,32 +96,3 @@ func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
users := make([]*entity.User, 0)
|
||||
for _, user := range r.users {
|
||||
if user.WorkspaceID == workspaceID {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) ListActive(ctx context.Context) ([]*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
users := make([]*entity.User, 0)
|
||||
for _, user := range r.users {
|
||||
if user.IsActive {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
type WorkspaceRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
workspaces map[string]*entity.Workspace
|
||||
}
|
||||
|
||||
func NewWorkspaceRepositoryMock() repository.WorkspaceRepository {
|
||||
repo := &WorkspaceRepositoryMock{workspaces: make(map[string]*entity.Workspace)}
|
||||
defaultWorkspace := entity.NewWorkspace(entity.DefaultWorkspaceName, "")
|
||||
defaultWorkspace.ID = entity.DefaultWorkspaceID
|
||||
repo.workspaces[defaultWorkspace.ID] = defaultWorkspace
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepositoryMock) Create(ctx context.Context, workspace *entity.Workspace) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if workspace.ID == "" {
|
||||
workspace.ID = uuid.New().String()
|
||||
}
|
||||
for _, existing := range r.workspaces {
|
||||
if existing.Name == workspace.Name {
|
||||
return entity.ErrWorkspaceExists
|
||||
}
|
||||
}
|
||||
copy := *workspace
|
||||
r.workspaces[workspace.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
workspace, ok := r.workspaces[id]
|
||||
if !ok {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
copy := *workspace
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, workspace := range r.workspaces {
|
||||
if workspace.Name == name {
|
||||
copy := *workspace
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepositoryMock) Update(ctx context.Context, workspace *entity.Workspace) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.workspaces[workspace.ID]; !ok {
|
||||
return entity.ErrWorkspaceNotFound
|
||||
}
|
||||
copy := *workspace
|
||||
r.workspaces[workspace.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepositoryMock) List(ctx context.Context) ([]*entity.Workspace, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]*entity.Workspace, 0, len(r.workspaces))
|
||||
for _, workspace := range r.workspaces {
|
||||
copy := *workspace
|
||||
result = append(result, ©)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type WorkspaceClusterBindingRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
bindings map[string]*entity.WorkspaceClusterBinding
|
||||
}
|
||||
|
||||
func NewWorkspaceClusterBindingRepositoryMock() repository.WorkspaceClusterBindingRepository {
|
||||
return &WorkspaceClusterBindingRepositoryMock{bindings: make(map[string]*entity.WorkspaceClusterBinding)}
|
||||
}
|
||||
|
||||
func bindingKey(workspaceID, clusterID string) string {
|
||||
return workspaceID + "/" + clusterID
|
||||
}
|
||||
|
||||
func (r *WorkspaceClusterBindingRepositoryMock) Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if binding.ID == "" {
|
||||
binding.ID = uuid.New().String()
|
||||
}
|
||||
copy := *binding
|
||||
r.bindings[bindingKey(binding.WorkspaceID, binding.ClusterID)] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceClusterBindingRepositoryMock) Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
binding, ok := r.bindings[bindingKey(workspaceID, clusterID)]
|
||||
if !ok {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
copy := *binding
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceClusterBindingRepositoryMock) Delete(ctx context.Context, workspaceID, clusterID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.bindings, bindingKey(workspaceID, clusterID))
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuditLogRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
logs []*entity.AuditLog
|
||||
}
|
||||
|
||||
func NewAuditLogRepositoryMock() repository.AuditLogRepository {
|
||||
return &AuditLogRepositoryMock{logs: make([]*entity.AuditLog, 0)}
|
||||
}
|
||||
|
||||
func (r *AuditLogRepositoryMock) Create(ctx context.Context, logEntry *entity.AuditLog) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if logEntry.ID == "" {
|
||||
logEntry.ID = uuid.New().String()
|
||||
}
|
||||
copy := *logEntry
|
||||
r.logs = append(r.logs, ©)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuditLogRepositoryMock) ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]*entity.AuditLog, 0)
|
||||
for i := len(r.logs) - 1; i >= 0; i-- {
|
||||
if r.logs[i].WorkspaceID == workspaceID {
|
||||
copy := *r.logs[i]
|
||||
result = append(result, ©)
|
||||
if limit > 0 && len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// AuditLogRepository PostgreSQL 审计日志仓储实现
|
||||
type AuditLogRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewAuditLogRepository 创建 PostgreSQL 审计日志仓储
|
||||
func NewAuditLogRepository(db *DB) repository.AuditLogRepository {
|
||||
return &AuditLogRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建审计日志
|
||||
func (r *AuditLogRepository) Create(ctx context.Context, log *entity.AuditLog) error {
|
||||
if log.ID == "" {
|
||||
log.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
detailsJSON, err := json.Marshal(log.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal details: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO audit_logs (id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
log.ID,
|
||||
log.WorkspaceID,
|
||||
log.UserID,
|
||||
log.Action,
|
||||
log.ResourceType,
|
||||
log.ResourceID,
|
||||
log.ResourceName,
|
||||
detailsJSON,
|
||||
log.IPAddress,
|
||||
log.UserAgent,
|
||||
log.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audit log: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的审计日志
|
||||
func (r *AuditLogRepository) GetByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanAuditLogs(rows)
|
||||
}
|
||||
|
||||
// GetByUser 获取用户的审计日志
|
||||
func (r *AuditLogRepository) GetByUser(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanAuditLogs(rows)
|
||||
}
|
||||
|
||||
// GetByResource 获取资源的审计日志
|
||||
func (r *AuditLogRepository) GetByResource(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, workspace_id, user_id, admin action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE resource_type = $1 AND resource_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, resourceType, resourceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanAuditLogs(rows)
|
||||
}
|
||||
|
||||
// List 列出审计日志(分页)
|
||||
func (r *AuditLogRepository) List(ctx context.Context, limit, offset int) ([]*entity.AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||
FROM audit_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanAuditLogs(rows)
|
||||
}
|
||||
|
||||
// DeleteByWorkspace 删除 workspace 的审计日志
|
||||
func (r *AuditLogRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
|
||||
query := `DELETE FROM audit_logs WHERE workspace_id = $1`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete audit logs: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanAuditLogs 扫描多行结果
|
||||
func (r *AuditLogRepository) scanAuditLogs(rows *sql.Rows) ([]*entity.AuditLog, error) {
|
||||
logs := make([]*entity.AuditLog, 0)
|
||||
for rows.Next() {
|
||||
log := &entity.AuditLog{}
|
||||
var detailsJSON []byte
|
||||
err := rows.Scan(
|
||||
&log.ID,
|
||||
&log.WorkspaceID,
|
||||
&log.UserID,
|
||||
&log.Action,
|
||||
&log.ResourceType,
|
||||
&log.ResourceID,
|
||||
&log.ResourceName,
|
||||
&detailsJSON,
|
||||
&log.IPAddress,
|
||||
&log.UserAgent,
|
||||
&log.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan audit log: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(detailsJSON, &log.Details); err != nil {
|
||||
log.Details = make(map[string]interface{})
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// ChartReferenceRepository PostgreSQL Chart 引用仓储实现
|
||||
type ChartReferenceRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewChartReferenceRepository 创建 PostgreSQL Chart 引用仓储
|
||||
func NewChartReferenceRepository(db *DB) repository.ChartReferenceRepository {
|
||||
return &ChartReferenceRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建 Chart 引用
|
||||
func (r *ChartReferenceRepository) Create(ctx context.Context, chartRef *entity.ChartReference) error {
|
||||
if chartRef.ID == "" {
|
||||
chartRef.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO chart_references (id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
chartRef.ID,
|
||||
chartRef.WorkspaceID,
|
||||
chartRef.RegistryID,
|
||||
chartRef.Repository,
|
||||
chartRef.ChartName,
|
||||
chartRef.Description,
|
||||
chartRef.IsEnabled,
|
||||
chartRef.CreatedAt,
|
||||
chartRef.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create chart reference: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取 Chart 引用
|
||||
func (r *ChartReferenceRepository) GetByID(ctx context.Context, id string) (*entity.ChartReference, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
|
||||
FROM chart_references
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
chartRef := &entity.ChartReference{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&chartRef.ID,
|
||||
&chartRef.WorkspaceID,
|
||||
&chartRef.RegistryID,
|
||||
&chartRef.Repository,
|
||||
&chartRef.ChartName,
|
||||
&chartRef.Description,
|
||||
&chartRef.IsEnabled,
|
||||
&chartRef.CreatedAt,
|
||||
&chartRef.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrChartReferenceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get chart reference: %w", err)
|
||||
}
|
||||
|
||||
return chartRef, nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的所有 Chart 引用
|
||||
func (r *ChartReferenceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
|
||||
FROM chart_references
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY chart_name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list chart references: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanChartReferences(rows)
|
||||
}
|
||||
|
||||
// GetByRegistry 获取 registry 的所有 Chart 引用
|
||||
func (r *ChartReferenceRepository) GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
|
||||
FROM chart_references
|
||||
WHERE registry_id = $1
|
||||
ORDER BY chart_name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, registryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list chart references: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanChartReferences(rows)
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取 Chart 引用
|
||||
func (r *ChartReferenceRepository) GetByName(ctx context.Context, workspaceID, chartName string) (*entity.ChartReference, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
|
||||
FROM chart_references
|
||||
WHERE workspace_id = $1 AND chart_name = $2
|
||||
`
|
||||
|
||||
chartRef := &entity.ChartReference{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartName).Scan(
|
||||
&chartRef.ID,
|
||||
&chartRef.WorkspaceID,
|
||||
&chartRef.RegistryID,
|
||||
&chartRef.Repository,
|
||||
&chartRef.ChartName,
|
||||
&chartRef.Description,
|
||||
&chartRef.IsEnabled,
|
||||
&chartRef.CreatedAt,
|
||||
&chartRef.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrChartReferenceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get chart reference: %w", err)
|
||||
}
|
||||
|
||||
return chartRef, nil
|
||||
}
|
||||
|
||||
// Update 更新 Chart 引用
|
||||
func (r *ChartReferenceRepository) Update(ctx context.Context, chartRef *entity.ChartReference) error {
|
||||
chartRef.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE chart_references
|
||||
SET registry_id = $1, repository = $2, chart_name = $3, description = $4, is_enabled = $5, updated_at = $6
|
||||
WHERE id = $7
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
chartRef.RegistryID,
|
||||
chartRef.Repository,
|
||||
chartRef.ChartName,
|
||||
chartRef.Description,
|
||||
chartRef.IsEnabled,
|
||||
chartRef.UpdatedAt,
|
||||
chartRef.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update chart reference: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrChartReferenceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 Chart 引用
|
||||
func (r *ChartReferenceRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM chart_references WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete chart reference: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrChartReferenceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有 Chart 引用(管理员用)
|
||||
func (r *ChartReferenceRepository) List(ctx context.Context) ([]*entity.ChartReference, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
|
||||
FROM chart_references
|
||||
ORDER BY workspace_id, chart_name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list chart references: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanChartReferences(rows)
|
||||
}
|
||||
|
||||
// scanChartReferences 扫描多行结果
|
||||
func (r *ChartReferenceRepository) scanChartReferences(rows *sql.Rows) ([]*entity.ChartReference, error) {
|
||||
chartRefs := make([]*entity.ChartReference, 0)
|
||||
for rows.Next() {
|
||||
chartRef := &entity.ChartReference{}
|
||||
err := rows.Scan(
|
||||
&chartRef.ID,
|
||||
&chartRef.WorkspaceID,
|
||||
&chartRef.RegistryID,
|
||||
&chartRef.Repository,
|
||||
&chartRef.ChartName,
|
||||
&chartRef.Description,
|
||||
&chartRef.IsEnabled,
|
||||
&chartRef.CreatedAt,
|
||||
&chartRef.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan chart reference: %w", err)
|
||||
}
|
||||
chartRefs = append(chartRefs, chartRef)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return chartRefs, nil
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -13,61 +12,33 @@ import (
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// ClusterRepository PostgreSQL 集群仓储实现
|
||||
type ClusterRepository struct {
|
||||
db *DB
|
||||
encryptor crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewClusterRepository 创建 PostgreSQL 集群仓储
|
||||
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||
return &ClusterRepository{
|
||||
db: db,
|
||||
encryptor: encryptor,
|
||||
}
|
||||
return &ClusterRepository{db: db, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// Create 创建集群
|
||||
func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||
if cluster.ID == "" {
|
||||
cluster.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if cluster.IsolationMode == "" {
|
||||
cluster.IsolationMode = entity.IsolationModeNamespace
|
||||
}
|
||||
|
||||
// 加密敏感数据
|
||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO clusters (id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
INSERT INTO clusters
|
||||
(id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
cluster.ID,
|
||||
cluster.WorkspaceID,
|
||||
cluster.OwnerID,
|
||||
cluster.Visibility,
|
||||
cluster.Name,
|
||||
cluster.Host,
|
||||
encryptedCAData,
|
||||
@ -75,164 +46,62 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
|
||||
encryptedKeyData,
|
||||
encryptedToken,
|
||||
cluster.Description,
|
||||
cluster.IsolationMode,
|
||||
cluster.DefaultNamespace,
|
||||
cluster.IsShared,
|
||||
cluster.CreatedAt,
|
||||
cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cluster: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取集群
|
||||
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
|
||||
FROM clusters
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&cluster.ID,
|
||||
&cluster.WorkspaceID,
|
||||
&cluster.OwnerID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.IsolationMode,
|
||||
&cluster.DefaultNamespace,
|
||||
&cluster.IsShared,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
|
||||
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
|
||||
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
|
||||
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
|
||||
|
||||
return cluster, nil
|
||||
return r.get(ctx, "id = $1", id)
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取集群
|
||||
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
|
||||
return r.get(ctx, "name = $1", name)
|
||||
}
|
||||
|
||||
func (r *ClusterRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Cluster, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at
|
||||
FROM clusters
|
||||
WHERE name = $1
|
||||
`
|
||||
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||
&cluster.ID,
|
||||
&cluster.WorkspaceID,
|
||||
&cluster.OwnerID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.IsolationMode,
|
||||
&cluster.DefaultNamespace,
|
||||
&cluster.IsShared,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
WHERE %s
|
||||
`, where)
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
|
||||
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
|
||||
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
|
||||
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
|
||||
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
cluster, err := r.scanCluster(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// decryptIfNeeded 解密数据。如果数据以 "apiVersion:" 或 "kind:" 开头(kubeconfig 格式),
|
||||
// 则跳过解密直接返回原值。
|
||||
func (r *ClusterRepository) decryptIfNeeded(data string, fieldName string) string {
|
||||
if data == "" {
|
||||
return ""
|
||||
}
|
||||
// 检测 kubeconfig 格式(明文 YAML)
|
||||
if (len(data) > 10 && data[:11] == "apiVersion:") ||
|
||||
(len(data) > 5 && data[:5] == "kind:") {
|
||||
return data
|
||||
}
|
||||
// 否则尝试解密
|
||||
decrypted, err := r.encryptor.Decrypt(data)
|
||||
if err != nil {
|
||||
log.Printf("[ClusterRepository] WARNING: failed to decrypt %s for field %s: %v (field will be empty)", data[:min(50, len(data))], fieldName, err)
|
||||
return ""
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// Update 更新集群
|
||||
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||
cluster.UpdatedAt = time.Now()
|
||||
|
||||
// 加密敏感数据
|
||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE clusters
|
||||
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
|
||||
token = $6, description = $7, isolation_mode = $8, default_namespace = $9,
|
||||
is_shared = $10, updated_at = $11
|
||||
WHERE id = $12
|
||||
SET workspace_id = $1, owner_id = $2, visibility = $3, name = $4, host = $5,
|
||||
ca_data = $6, cert_data = $7, key_data = $8, token = $9, description = $10,
|
||||
default_namespace = $11, updated_at = $12
|
||||
WHERE id = $13
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
cluster.WorkspaceID,
|
||||
cluster.OwnerID,
|
||||
cluster.Visibility,
|
||||
cluster.Name,
|
||||
cluster.Host,
|
||||
encryptedCAData,
|
||||
@ -240,160 +109,134 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
|
||||
encryptedKeyData,
|
||||
encryptedToken,
|
||||
cluster.Description,
|
||||
cluster.IsolationMode,
|
||||
cluster.DefaultNamespace,
|
||||
cluster.IsShared,
|
||||
cluster.UpdatedAt,
|
||||
cluster.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update cluster: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除集群
|
||||
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM clusters WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM clusters WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete cluster: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有集群
|
||||
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
|
||||
SELECT id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at
|
||||
FROM clusters
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanClusters(rows)
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的所有集群(包括共享集群)
|
||||
func (r *ClusterRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
|
||||
FROM clusters
|
||||
WHERE workspace_id = $1 OR is_shared = TRUE
|
||||
ORDER BY is_shared, created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list clusters by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanClusters(rows)
|
||||
}
|
||||
|
||||
// GetShared 获取所有共享集群
|
||||
func (r *ClusterRepository) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
|
||||
FROM clusters
|
||||
WHERE is_shared = TRUE
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list shared clusters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanClusters(rows)
|
||||
}
|
||||
|
||||
// scanClusters 扫描多行结果
|
||||
func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, error) {
|
||||
clusters := make([]*entity.Cluster, 0)
|
||||
for rows.Next() {
|
||||
cluster := &entity.Cluster{}
|
||||
var (
|
||||
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
|
||||
workspaceID, ownerID, defaultNamespace sql.NullString
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&cluster.ID,
|
||||
&workspaceID,
|
||||
&ownerID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.IsolationMode,
|
||||
&defaultNamespace,
|
||||
&cluster.IsShared,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
cluster, err := r.scanCluster(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 处理 NULL 值
|
||||
cluster.WorkspaceID = workspaceID.String
|
||||
cluster.OwnerID = ownerID.String
|
||||
cluster.DefaultNamespace = defaultNamespace.String
|
||||
|
||||
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||
if encryptedCAData.Valid {
|
||||
cluster.CAData = r.decryptIfNeeded(encryptedCAData.String, "ca_data")
|
||||
}
|
||||
if encryptedCertData.Valid {
|
||||
cluster.CertData = r.decryptIfNeeded(encryptedCertData.String, "cert_data")
|
||||
}
|
||||
if encryptedKeyData.Valid {
|
||||
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData.String, "key_data")
|
||||
}
|
||||
if encryptedToken.Valid {
|
||||
cluster.Token = r.decryptIfNeeded(encryptedToken.String, "token")
|
||||
}
|
||||
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
type clusterScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (r *ClusterRepository) scanCluster(scanner clusterScanner) (*entity.Cluster, error) {
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
|
||||
var defaultNamespace sql.NullString
|
||||
err := scanner.Scan(
|
||||
&cluster.ID,
|
||||
&cluster.WorkspaceID,
|
||||
&cluster.OwnerID,
|
||||
&cluster.Visibility,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&defaultNamespace,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
||||
}
|
||||
cluster.DefaultNamespace = defaultNamespace.String
|
||||
var decryptErr error
|
||||
cluster.CAData, decryptErr = decryptMaybe(r.encryptor, encryptedCAData.String)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt CA data: %w", decryptErr)
|
||||
}
|
||||
cluster.CertData, decryptErr = decryptMaybe(r.encryptor, encryptedCertData.String)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt cert data: %w", decryptErr)
|
||||
}
|
||||
cluster.KeyData, decryptErr = decryptMaybe(r.encryptor, encryptedKeyData.String)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt key data: %w", decryptErr)
|
||||
}
|
||||
cluster.Token, decryptErr = decryptMaybe(r.encryptor, encryptedToken.String)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", decryptErr)
|
||||
}
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepository) encryptClusterSecrets(cluster *entity.Cluster) (string, string, string, string, error) {
|
||||
ca, err := r.encryptor.Encrypt(cluster.CAData)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||
}
|
||||
cert, err := r.encryptor.Encrypt(cluster.CertData)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||
}
|
||||
key, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to encrypt key data: %w", err)
|
||||
}
|
||||
token, err := r.encryptor.Encrypt(cluster.Token)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
return ca, cert, key, token, nil
|
||||
}
|
||||
|
||||
func decryptMaybe(encryptor crypto.Encryptor, value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
return encryptor.Decrypt(value)
|
||||
}
|
||||
|
||||
@ -53,21 +53,69 @@ func (db *DB) GetConn() *sql.DB {
|
||||
// InitSchema 初始化数据库 schema
|
||||
func (db *DB) InitSchema() error {
|
||||
schema := `
|
||||
-- Workspaces 表
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
k8s_namespace VARCHAR(255) NOT NULL,
|
||||
k8s_sa_name VARCHAR(255) NOT NULL,
|
||||
default_cluster_id VARCHAR(36),
|
||||
quota_cpu VARCHAR(50),
|
||||
quota_memory VARCHAR(50),
|
||||
quota_gpu VARCHAR(50),
|
||||
quota_gpu_memory VARCHAR(50),
|
||||
created_by VARCHAR(36),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS default_cluster_id VARCHAR(36),
|
||||
ADD COLUMN IF NOT EXISTS quota_cpu VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS quota_memory VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS quota_gpu VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS quota_gpu_memory VARCHAR(50);
|
||||
|
||||
INSERT INTO workspaces (id, name, status, k8s_namespace, k8s_sa_name, created_at, updated_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000010', 'default', 'active', 'ocdp-ws-default', 'ocdp-ws-default', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Users 表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revoked_after TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS revoked_after TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||
|
||||
UPDATE users SET role = 'admin' WHERE username = 'admin';
|
||||
UPDATE users SET workspace_id = '00000000-0000-0000-0000-000000000010' WHERE workspace_id = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_workspace ON users(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_revoked_after ON users(revoked_after);
|
||||
|
||||
-- Clusters 表
|
||||
CREATE TABLE IF NOT EXISTS clusters (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
host TEXT NOT NULL,
|
||||
ca_data TEXT,
|
||||
@ -75,15 +123,29 @@ func (db *DB) InitSchema() error {
|
||||
key_data TEXT,
|
||||
token TEXT,
|
||||
description TEXT,
|
||||
default_namespace VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE clusters
|
||||
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||
ADD COLUMN IF NOT EXISTS default_namespace VARCHAR(255);
|
||||
UPDATE clusters SET visibility = 'global_shared' WHERE visibility = 'private' AND owner_id = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_clusters_workspace ON clusters(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clusters_owner ON clusters(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clusters_visibility ON clusters(visibility);
|
||||
|
||||
-- Registries 表
|
||||
CREATE TABLE IF NOT EXISTS registries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT,
|
||||
@ -94,11 +156,22 @@ func (db *DB) InitSchema() error {
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE registries
|
||||
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50) NOT NULL DEFAULT 'private';
|
||||
UPDATE registries SET visibility = 'global_shared' WHERE visibility = 'private' AND owner_id = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_registries_name ON registries(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_registries_workspace ON registries(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_registries_visibility ON registries(visibility);
|
||||
|
||||
-- Instances 表
|
||||
CREATE TABLE IF NOT EXISTS instances (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
cluster_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
namespace VARCHAR(255) NOT NULL,
|
||||
@ -121,61 +194,63 @@ func (db *DB) InitSchema() error {
|
||||
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace)
|
||||
);
|
||||
|
||||
ALTER TABLE instances
|
||||
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_workspace ON instances(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_instances_owner ON instances(owner_id);
|
||||
|
||||
-- Storage Backends 表
|
||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||
CREATE TABLE IF NOT EXISTS workspace_cluster_bindings (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36),
|
||||
owner_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
config JSONB NOT NULL,
|
||||
description TEXT,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_shared BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id);
|
||||
|
||||
-- Chart References 表
|
||||
CREATE TABLE IF NOT EXISTS chart_references (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36),
|
||||
registry_id VARCHAR(36),
|
||||
repository VARCHAR(500) NOT NULL,
|
||||
chart_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
is_enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chart_workspace ON chart_references(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chart_registry ON chart_references(registry_id);
|
||||
|
||||
-- Values Templates 表 - 使用复合唯一键替代主键,允许同一模板的多个版本
|
||||
CREATE TABLE IF NOT EXISTS values_templates (
|
||||
id VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
owner_id VARCHAR(36),
|
||||
chart_reference_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
values_yaml TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
workspace_id VARCHAR(36) NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
cluster_id VARCHAR(36) NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
namespace VARCHAR(255) NOT NULL,
|
||||
service_account VARCHAR(255) NOT NULL,
|
||||
quota_cpu VARCHAR(50),
|
||||
quota_memory VARCHAR(50),
|
||||
quota_gpu VARCHAR(50),
|
||||
quota_gpu_memory VARCHAR(50),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (chart_reference_id, name, version)
|
||||
UNIQUE (workspace_id, cluster_id)
|
||||
);
|
||||
ALTER TABLE workspace_cluster_bindings
|
||||
ADD COLUMN IF NOT EXISTS quota_gpu_memory VARCHAR(50);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_cluster_bindings_workspace ON workspace_cluster_bindings(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_cluster_bindings_cluster ON workspace_cluster_bindings(cluster_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_quotas (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
hard_limit VARCHAR(100) NOT NULL,
|
||||
soft_limit VARCHAR(100),
|
||||
used VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (workspace_id, resource_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_values_template_workspace ON values_templates(workspace_id);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36),
|
||||
user_id VARCHAR(36),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_id VARCHAR(36),
|
||||
resource_name VARCHAR(255),
|
||||
details JSONB,
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_workspace ON audit_logs(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(schema)
|
||||
|
||||
@ -12,37 +12,32 @@ import (
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// InstanceRepository PostgreSQL 实例仓储实现
|
||||
type InstanceRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewInstanceRepository 创建 PostgreSQL 实例仓储
|
||||
func NewInstanceRepository(db *DB) repository.InstanceRepository {
|
||||
return &InstanceRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建实例
|
||||
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
|
||||
if instance.ID == "" {
|
||||
instance.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 将 Values 转换为 JSON
|
||||
valuesJSON, err := json.Marshal(instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal values: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO instances (id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
INSERT INTO instances
|
||||
(id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error, revision, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
instance.ID,
|
||||
instance.WorkspaceID,
|
||||
instance.OwnerID,
|
||||
instance.ClusterID,
|
||||
instance.Name,
|
||||
instance.Namespace,
|
||||
@ -61,166 +56,71 @@ func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instan
|
||||
instance.CreatedAt,
|
||||
instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取实例
|
||||
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
return r.get(ctx, "id = $1", id)
|
||||
}
|
||||
|
||||
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||
func (r *InstanceRepository) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE cluster_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, clusterID, name).Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, clusterID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
return r.scanInstance(rows)
|
||||
}
|
||||
|
||||
func (r *InstanceRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Instance, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE %s
|
||||
`, where)
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
return r.scanInstance(rows)
|
||||
}
|
||||
|
||||
// Update 更新实例
|
||||
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
|
||||
instance.UpdatedAt = time.Now()
|
||||
|
||||
// 将 Values 转换为 JSON
|
||||
valuesJSON, err := json.Marshal(instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal values: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE instances
|
||||
SET cluster_id = $1, name = $2, namespace = $3, registry_id = $4, repository = $5,
|
||||
chart = $6, version = $7, description = $8, values = $9, values_yaml = $10,
|
||||
status = $11, status_reason = $12, last_operation = $13, last_error = $14,
|
||||
revision = $15, updated_at = $16
|
||||
WHERE id = $17
|
||||
SET workspace_id = $1, owner_id = $2, cluster_id = $3, name = $4, namespace = $5,
|
||||
registry_id = $6, repository = $7, chart = $8, version = $9, description = $10,
|
||||
values = $11, values_yaml = $12, status = $13, status_reason = $14,
|
||||
last_operation = $15, last_error = $16, revision = $17, updated_at = $18
|
||||
WHERE id = $19
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
instance.WorkspaceID,
|
||||
instance.OwnerID,
|
||||
instance.ClusterID,
|
||||
instance.Name,
|
||||
instance.Namespace,
|
||||
@ -239,297 +139,126 @@ func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instan
|
||||
instance.UpdatedAt,
|
||||
instance.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update instance: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除实例
|
||||
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM instances WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM instances WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByCluster 列出指定集群的所有实例
|
||||
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE cluster_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for rows.Next() {
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
return r.list(ctx, "WHERE cluster_id = $1", clusterID)
|
||||
}
|
||||
|
||||
// List 列出所有实例
|
||||
func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||
return r.list(ctx, "", nil)
|
||||
}
|
||||
|
||||
func (r *InstanceRepository) list(ctx context.Context, where string, arg interface{}) ([]*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
` + where + `
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if where == "" {
|
||||
rows, err = r.db.conn.QueryContext(ctx, query)
|
||||
} else {
|
||||
rows, err = r.db.conn.QueryContext(ctx, query, arg)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for rows.Next() {
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
instance, err := r.scanInstance(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 列出指定工作空间的所有实例(用于配额检查)
|
||||
func (r *InstanceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, workspace_id, owner_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, values_template_id, user_override_yaml,
|
||||
status, status_reason, last_operation, last_error, revision,
|
||||
cpu_requested, memory_requested, gpu_requested, gpu_memory_requested,
|
||||
created_at, updated_at
|
||||
FROM instances
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
type instanceScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Instance, error) {
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&instance.ID,
|
||||
&instance.WorkspaceID,
|
||||
&instance.OwnerID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instances by workspace: %w", err)
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for rows.Next() {
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
valuesTemplateID sql.NullString
|
||||
userOverrideYAML sql.NullString
|
||||
memoryRequested sql.NullString
|
||||
gpuMemoryRequested sql.NullString
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.WorkspaceID,
|
||||
&instance.OwnerID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&valuesTemplateID,
|
||||
&userOverrideYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CPURequested,
|
||||
&memoryRequested,
|
||||
&instance.GPURequested,
|
||||
&gpuMemoryRequested,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
|
||||
if valuesJSON != nil {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
if valuesTemplateID.Valid {
|
||||
instance.ValuesTemplateID = valuesTemplateID.String
|
||||
}
|
||||
if userOverrideYAML.Valid {
|
||||
instance.UserOverrideYAML = userOverrideYAML.String
|
||||
}
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
if memoryRequested.Valid {
|
||||
instance.MemoryRequested = memoryRequested.String
|
||||
}
|
||||
if gpuMemoryRequested.Valid {
|
||||
instance.GPUMemoryRequested = gpuMemoryRequested.String
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
@ -1,212 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// QuotaRepository PostgreSQL 配额仓储实现
|
||||
type QuotaRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewQuotaRepository 创建 PostgreSQL 配额仓储
|
||||
func NewQuotaRepository(db *DB) repository.QuotaRepository {
|
||||
return &QuotaRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建配额
|
||||
func (r *QuotaRepository) Create(ctx context.Context, quota *entity.WorkspaceQuota) error {
|
||||
if quota.ID == "" {
|
||||
quota.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO workspace_quotas (id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_id, resource_type) DO UPDATE
|
||||
SET hard_limit = $4, soft_limit = $5, updated_at = $8
|
||||
`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
quota.ID,
|
||||
quota.WorkspaceID,
|
||||
quota.ResourceType,
|
||||
quota.HardLimit,
|
||||
quota.SoftLimit,
|
||||
quota.Used,
|
||||
quota.CreatedAt,
|
||||
quota.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create quota: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取配额
|
||||
func (r *QuotaRepository) GetByID(ctx context.Context, id string) (*entity.WorkspaceQuota, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
|
||||
FROM workspace_quotas
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
quota := &entity.WorkspaceQuota{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
"a.ID,
|
||||
"a.WorkspaceID,
|
||||
"a.ResourceType,
|
||||
"a.HardLimit,
|
||||
"a.SoftLimit,
|
||||
"a.Used,
|
||||
"a.CreatedAt,
|
||||
"a.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get quota: %w", err)
|
||||
}
|
||||
|
||||
return quota, nil
|
||||
}
|
||||
|
||||
// GetByWorkspaceAndType 根据 workspace 和资源类型获取配额
|
||||
func (r *QuotaRepository) GetByWorkspaceAndType(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
|
||||
FROM workspace_quotas
|
||||
WHERE workspace_id = $1 AND resource_type = $2
|
||||
`
|
||||
|
||||
quota := &entity.WorkspaceQuota{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, resourceType).Scan(
|
||||
"a.ID,
|
||||
"a.WorkspaceID,
|
||||
"a.ResourceType,
|
||||
"a.HardLimit,
|
||||
"a.SoftLimit,
|
||||
"a.Used,
|
||||
"a.CreatedAt,
|
||||
"a.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get quota: %w", err)
|
||||
}
|
||||
|
||||
return quota, nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的所有配额
|
||||
func (r *QuotaRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
|
||||
FROM workspace_quotas
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY resource_type
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list quotas: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
quotas := make([]*entity.WorkspaceQuota, 0)
|
||||
for rows.Next() {
|
||||
quota := &entity.WorkspaceQuota{}
|
||||
err := rows.Scan(
|
||||
"a.ID,
|
||||
"a.WorkspaceID,
|
||||
"a.ResourceType,
|
||||
"a.HardLimit,
|
||||
"a.SoftLimit,
|
||||
"a.Used,
|
||||
"a.CreatedAt,
|
||||
"a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan quota: %w", err)
|
||||
}
|
||||
quotas = append(quotas, quota)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return quotas, nil
|
||||
}
|
||||
|
||||
// Update 更新配额
|
||||
func (r *QuotaRepository) Update(ctx context.Context, quota *entity.WorkspaceQuota) error {
|
||||
quota.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE workspace_quotas
|
||||
SET hard_limit = $1, soft_limit = $2, used = $3, updated_at = $4
|
||||
WHERE id = $5
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
quota.HardLimit,
|
||||
quota.SoftLimit,
|
||||
quota.Used,
|
||||
quota.UpdatedAt,
|
||||
quota.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update quota: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("quota not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除配额
|
||||
func (r *QuotaRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM workspace_quotas WHERE id = $1`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete quota: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByWorkspace 删除 workspace 的所有配额
|
||||
func (r *QuotaRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
|
||||
query := `DELETE FROM workspace_quotas WHERE workspace_id = $1`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete quotas: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -12,39 +12,32 @@ import (
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// RegistryRepository PostgreSQL Registry 仓储实现
|
||||
type RegistryRepository struct {
|
||||
db *DB
|
||||
encryptor crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewRegistryRepository 创建 PostgreSQL Registry 仓储
|
||||
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||
return &RegistryRepository{
|
||||
db: db,
|
||||
encryptor: encryptor,
|
||||
}
|
||||
return &RegistryRepository{db: db, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// Create 创建 Registry
|
||||
func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
|
||||
if registry.ID == "" {
|
||||
registry.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO registries (id, name, url, description, username, password, insecure, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
INSERT INTO registries (id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
registry.ID,
|
||||
registry.WorkspaceID,
|
||||
registry.OwnerID,
|
||||
registry.Visibility,
|
||||
registry.Name,
|
||||
registry.URL,
|
||||
registry.Description,
|
||||
@ -54,120 +47,57 @@ func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Regist
|
||||
registry.CreatedAt,
|
||||
registry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取 Registry
|
||||
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
|
||||
FROM registries
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword, workspaceID, ownerID sql.NullString
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
®istry.ID,
|
||||
&workspaceID,
|
||||
&ownerID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.IsShared,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||
}
|
||||
|
||||
registry.WorkspaceID = workspaceID.String
|
||||
registry.OwnerID = ownerID.String
|
||||
|
||||
// 解密密码(如果失败则保持为空,与 List 行为一致)
|
||||
if encryptedPassword.Valid {
|
||||
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
|
||||
}
|
||||
|
||||
return registry, nil
|
||||
return r.get(ctx, "id = $1", id)
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取 Registry
|
||||
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
|
||||
return r.get(ctx, "name = $1", name)
|
||||
}
|
||||
|
||||
func (r *RegistryRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Registry, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at
|
||||
FROM registries
|
||||
WHERE name = $1
|
||||
`
|
||||
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword, workspaceID, ownerID sql.NullString
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||
®istry.ID,
|
||||
&workspaceID,
|
||||
&ownerID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.IsShared,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
WHERE %s
|
||||
`, where)
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||
}
|
||||
|
||||
registry.WorkspaceID = workspaceID.String
|
||||
registry.OwnerID = ownerID.String
|
||||
|
||||
// 解密密码(如果失败则保持为空,与 List 行为一致)
|
||||
if encryptedPassword.Valid {
|
||||
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
registry, err := r.scanRegistry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// Update 更新 Registry
|
||||
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
|
||||
registry.UpdatedAt = time.Now()
|
||||
|
||||
// 加密密码
|
||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE registries
|
||||
SET name = $1, url = $2, description = $3, username = $4, password = $5,
|
||||
insecure = $6, updated_at = $7
|
||||
WHERE id = $8
|
||||
SET workspace_id = $1, owner_id = $2, visibility = $3, name = $4, url = $5,
|
||||
description = $6, username = $7, password = $8, insecure = $9, updated_at = $10
|
||||
WHERE id = $11
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
registry.WorkspaceID,
|
||||
registry.OwnerID,
|
||||
registry.Visibility,
|
||||
registry.Name,
|
||||
registry.URL,
|
||||
registry.Description,
|
||||
@ -177,97 +107,86 @@ func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Regist
|
||||
registry.UpdatedAt,
|
||||
registry.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update registry: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 Registry
|
||||
func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM registries WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM registries WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete registry: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有 Registries
|
||||
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
|
||||
SELECT id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at
|
||||
FROM registries
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list registries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
registries := make([]*entity.Registry, 0)
|
||||
for rows.Next() {
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword, workspaceID, ownerID sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
®istry.ID,
|
||||
&workspaceID,
|
||||
&ownerID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.IsShared,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
registry, err := r.scanRegistry(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 处理 NULL 值
|
||||
registry.WorkspaceID = workspaceID.String
|
||||
registry.OwnerID = ownerID.String
|
||||
|
||||
// 解密密码
|
||||
if encryptedPassword.Valid {
|
||||
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
|
||||
}
|
||||
|
||||
registries = append(registries, registry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
type registryScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (r *RegistryRepository) scanRegistry(scanner registryScanner) (*entity.Registry, error) {
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword sql.NullString
|
||||
err := scanner.Scan(
|
||||
®istry.ID,
|
||||
®istry.WorkspaceID,
|
||||
®istry.OwnerID,
|
||||
®istry.Visibility,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
||||
}
|
||||
registry.Password, err = decryptMaybe(r.encryptor, encryptedPassword.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
@ -1,417 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// sqlNullString converts empty string to sql.NullString for proper NULL handling
|
||||
func sqlNullString(s string) interface{} {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// StorageRepository PostgreSQL 存储后端仓储实现
|
||||
type StorageRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewStorageRepository 创建 PostgreSQL 存储后端仓储
|
||||
func NewStorageRepository(db *DB) repository.StorageRepository {
|
||||
return &StorageRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建存储后端
|
||||
func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageBackend) error {
|
||||
if storage.ID == "" {
|
||||
storage.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
configJSON, err := json.Marshal(storage.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO storage_backends (id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
storage.ID,
|
||||
storage.WorkspaceID,
|
||||
sqlNullString(storage.ClusterID),
|
||||
sqlNullString(storage.OwnerID),
|
||||
storage.Name,
|
||||
storage.Type,
|
||||
configJSON,
|
||||
storage.Description,
|
||||
storage.IsDefault,
|
||||
storage.IsShared,
|
||||
storage.CreatedAt,
|
||||
storage.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取存储后端
|
||||
func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
storage := &entity.StorageBackend{}
|
||||
var configJSON []byte
|
||||
var wsID, clusterID, ownerID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&storage.ID,
|
||||
&wsID,
|
||||
&clusterID,
|
||||
&ownerID,
|
||||
&storage.Name,
|
||||
&storage.Type,
|
||||
&configJSON,
|
||||
&storage.Description,
|
||||
&storage.IsDefault,
|
||||
&storage.IsShared,
|
||||
&storage.CreatedAt,
|
||||
&storage.UpdatedAt,
|
||||
)
|
||||
storage.WorkspaceID = wsID.String
|
||||
storage.ClusterID = clusterID.String
|
||||
storage.OwnerID = ownerID.String
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrStorageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get storage: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取存储后端
|
||||
func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
storage := &entity.StorageBackend{}
|
||||
var configJSON []byte
|
||||
var wsID, clusterID, ownerID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
|
||||
&storage.ID,
|
||||
&wsID,
|
||||
&clusterID,
|
||||
&ownerID,
|
||||
&storage.Name,
|
||||
&storage.Type,
|
||||
&configJSON,
|
||||
&storage.Description,
|
||||
&storage.IsDefault,
|
||||
&storage.IsShared,
|
||||
&storage.CreatedAt,
|
||||
&storage.UpdatedAt,
|
||||
)
|
||||
storage.WorkspaceID = wsID.String
|
||||
storage.ClusterID = clusterID.String
|
||||
storage.OwnerID = ownerID.String
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrStorageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get storage: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的所有存储后端
|
||||
func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE workspace_id = $1 OR is_shared = TRUE
|
||||
ORDER BY is_default DESC, name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storage: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanStorages(rows)
|
||||
}
|
||||
|
||||
// GetShared 获取所有共享存储后端
|
||||
func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE is_shared = TRUE
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list shared storage: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanStorages(rows)
|
||||
}
|
||||
|
||||
// GetDefault 获取 workspace 的默认存储后端
|
||||
func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE workspace_id = $1 AND is_default = TRUE
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
storage := &entity.StorageBackend{}
|
||||
var configJSON []byte
|
||||
var wsID, clusterID, ownerID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
|
||||
&storage.ID,
|
||||
&wsID,
|
||||
&clusterID,
|
||||
&ownerID,
|
||||
&storage.Name,
|
||||
&storage.Type,
|
||||
&configJSON,
|
||||
&storage.Description,
|
||||
&storage.IsDefault,
|
||||
&storage.IsShared,
|
||||
&storage.CreatedAt,
|
||||
&storage.UpdatedAt,
|
||||
)
|
||||
storage.WorkspaceID = wsID.String
|
||||
storage.ClusterID = clusterID.String
|
||||
storage.OwnerID = ownerID.String
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default storage: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// GetByCluster 获取 cluster 关联的存储后端列表
|
||||
func (r *StorageRepository) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE cluster_id = $1
|
||||
ORDER BY is_default DESC, name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storage by cluster: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanStorages(rows)
|
||||
}
|
||||
|
||||
// GetDefaultByCluster 获取 cluster 的默认存储后端
|
||||
func (r *StorageRepository) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
WHERE cluster_id = $1 AND is_default = TRUE
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
storage := &entity.StorageBackend{}
|
||||
var configJSON []byte
|
||||
var wsID, clusterIDNull, ownerID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, clusterID).Scan(
|
||||
&storage.ID,
|
||||
&wsID,
|
||||
&clusterIDNull,
|
||||
&ownerID,
|
||||
&storage.Name,
|
||||
&storage.Type,
|
||||
&configJSON,
|
||||
&storage.Description,
|
||||
&storage.IsDefault,
|
||||
&storage.IsShared,
|
||||
&storage.CreatedAt,
|
||||
&storage.UpdatedAt,
|
||||
)
|
||||
storage.WorkspaceID = wsID.String
|
||||
storage.ClusterID = clusterIDNull.String
|
||||
storage.OwnerID = ownerID.String
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default storage by cluster: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// Update 更新存储后端
|
||||
func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error {
|
||||
storage.UpdatedAt = time.Now()
|
||||
|
||||
configJSON, err := json.Marshal(storage.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE storage_backends
|
||||
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, cluster_id = $7, updated_at = $8
|
||||
WHERE id = $9
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
storage.Name,
|
||||
storage.Type,
|
||||
configJSON,
|
||||
storage.Description,
|
||||
storage.IsDefault,
|
||||
storage.IsShared,
|
||||
sqlNullString(storage.ClusterID),
|
||||
storage.UpdatedAt,
|
||||
storage.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrStorageNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除存储后端
|
||||
func (r *StorageRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM storage_backends WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrStorageNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有存储后端(管理员用)
|
||||
func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
|
||||
FROM storage_backends
|
||||
ORDER BY workspace_id, name
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storage: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanStorages(rows)
|
||||
}
|
||||
|
||||
// scanStorages 扫描多行结果
|
||||
func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBackend, error) {
|
||||
storages := make([]*entity.StorageBackend, 0)
|
||||
for rows.Next() {
|
||||
storage := &entity.StorageBackend{}
|
||||
var configJSON []byte
|
||||
var wsID, clusterID sql.NullString
|
||||
err := rows.Scan(
|
||||
&storage.ID,
|
||||
&wsID,
|
||||
&clusterID,
|
||||
&storage.OwnerID,
|
||||
&storage.Name,
|
||||
&storage.Type,
|
||||
&configJSON,
|
||||
&storage.Description,
|
||||
&storage.IsDefault,
|
||||
&storage.IsShared,
|
||||
&storage.CreatedAt,
|
||||
&storage.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan storage: %w", err)
|
||||
}
|
||||
storage.WorkspaceID = wsID.String
|
||||
storage.ClusterID = clusterID.String
|
||||
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
storages = append(storages, storage)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return storages, nil
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -28,11 +27,6 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if user.IsActive {
|
||||
user.IsActive = true
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
@ -68,14 +62,13 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
|
||||
`
|
||||
|
||||
user := &entity.User{}
|
||||
var workspaceID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&workspaceID,
|
||||
&user.WorkspaceID,
|
||||
&user.IsActive,
|
||||
&user.MustChangePassword,
|
||||
&user.RevokedAfter,
|
||||
@ -83,13 +76,6 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
// Handle NULL workspace_id
|
||||
if workspaceID.Valid {
|
||||
user.WorkspaceID = workspaceID.String
|
||||
} else {
|
||||
user.WorkspaceID = ""
|
||||
}
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
@ -102,24 +88,20 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
|
||||
|
||||
// GetByUsername 根据用户名获取用户
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||
log.Printf("[DEBUG] GetByUsername called with username: %q", username)
|
||||
query := `
|
||||
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
|
||||
log.Printf("[DEBUG] Executing query: %s with param: %s", query, username)
|
||||
|
||||
user := &entity.User{}
|
||||
var workspaceID sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&workspaceID,
|
||||
&user.WorkspaceID,
|
||||
&user.IsActive,
|
||||
&user.MustChangePassword,
|
||||
&user.RevokedAfter,
|
||||
@ -127,25 +109,13 @@ func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*e
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
// Handle NULL workspace_id
|
||||
if workspaceID.Valid {
|
||||
user.WorkspaceID = workspaceID.String
|
||||
} else {
|
||||
user.WorkspaceID = ""
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] Query result - err: %v", err)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("[DEBUG] User not found in DB")
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] Scan error: %v", err)
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] Found user: %+v", user)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@ -155,7 +125,8 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
|
||||
|
||||
query := `
|
||||
UPDATE users
|
||||
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5, is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
|
||||
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5,
|
||||
is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
|
||||
WHERE id = $10
|
||||
`
|
||||
|
||||
@ -251,92 +222,3 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// ListByWorkspace 列出指定 workspace 的用户
|
||||
func (r *UserRepository) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
|
||||
query := `
|
||||
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||
FROM users
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list users by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]*entity.User, 0)
|
||||
for rows.Next() {
|
||||
user := &entity.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&user.WorkspaceID,
|
||||
&user.IsActive,
|
||||
&user.MustChangePassword,
|
||||
&user.RevokedAfter,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan user: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// ListActive 仅列出活跃用户
|
||||
func (r *UserRepository) ListActive(ctx context.Context) ([]*entity.User, error) {
|
||||
query := `
|
||||
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||
FROM users
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list active users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]*entity.User, 0)
|
||||
for rows.Next() {
|
||||
user := &entity.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.Role,
|
||||
&user.WorkspaceID,
|
||||
&user.IsActive,
|
||||
&user.MustChangePassword,
|
||||
&user.RevokedAfter,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan user: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,287 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// ValuesTemplateRepository PostgreSQL Values 模板仓储实现
|
||||
type ValuesTemplateRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewValuesTemplateRepository 创建 PostgreSQL Values 模板仓储
|
||||
func NewValuesTemplateRepository(db *DB) repository.ValuesTemplateRepository {
|
||||
return &ValuesTemplateRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建 Values 模板
|
||||
func (r *ValuesTemplateRepository) Create(ctx context.Context, template *entity.ValuesTemplate) error {
|
||||
if template.ID == "" {
|
||||
template.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
template.ID,
|
||||
template.WorkspaceID,
|
||||
template.OwnerID,
|
||||
template.ChartReferenceID,
|
||||
template.Name,
|
||||
template.Description,
|
||||
template.ValuesYAML,
|
||||
template.Version,
|
||||
template.IsDefault,
|
||||
template.CreatedAt,
|
||||
template.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create values template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取 Values 模板
|
||||
func (r *ValuesTemplateRepository) GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
template := &entity.ValuesTemplate{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&template.ID,
|
||||
&template.WorkspaceID,
|
||||
&template.OwnerID,
|
||||
&template.ChartReferenceID,
|
||||
&template.Name,
|
||||
&template.Description,
|
||||
&template.ValuesYAML,
|
||||
&template.Version,
|
||||
&template.IsDefault,
|
||||
&template.CreatedAt,
|
||||
&template.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get values template: %w", err)
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// GetByWorkspace 获取 workspace 的所有 Values 模板
|
||||
func (r *ValuesTemplateRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY chart_reference_id, name, version DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list values templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanValuesTemplates(rows)
|
||||
}
|
||||
|
||||
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
|
||||
func (r *ValuesTemplateRepository) GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
WHERE chart_reference_id = $1
|
||||
ORDER BY name, version DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list values templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanValuesTemplates(rows)
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取 Values 模板(获取最新版本)
|
||||
func (r *ValuesTemplateRepository) GetByName(ctx context.Context, workspaceID, chartRefID, name string) (*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
WHERE workspace_id = $1 AND chart_reference_id = $2 AND name = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
template := &entity.ValuesTemplate{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartRefID, name).Scan(
|
||||
&template.ID,
|
||||
&template.WorkspaceID,
|
||||
&template.OwnerID,
|
||||
&template.ChartReferenceID,
|
||||
&template.Name,
|
||||
&template.Description,
|
||||
&template.ValuesYAML,
|
||||
&template.Version,
|
||||
&template.IsDefault,
|
||||
&template.CreatedAt,
|
||||
&template.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get values template: %w", err)
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// GetHistory 获取模板的版本历史
|
||||
func (r *ValuesTemplateRepository) GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
WHERE chart_reference_id = $1 AND name = $2
|
||||
ORDER BY version DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get values template history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanValuesTemplates(rows)
|
||||
}
|
||||
|
||||
// Update 更新 Values 模板(自动递增版本)
|
||||
func (r *ValuesTemplateRepository) Update(ctx context.Context, template *entity.ValuesTemplate) error {
|
||||
// 获取当前最大版本号
|
||||
var maxVersion int
|
||||
err := r.db.conn.QueryRowContext(ctx,
|
||||
"SELECT COALESCE(MAX(version), 0) FROM values_templates WHERE chart_reference_id = $1 AND name = $2",
|
||||
template.ChartReferenceID, template.Name,
|
||||
).Scan(&maxVersion)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get max version: %w", err)
|
||||
}
|
||||
|
||||
// 生成新 ID 用于新版本
|
||||
newID := uuid.New().String()
|
||||
template.Version = maxVersion + 1
|
||||
template.UpdatedAt = time.Now()
|
||||
template.CreatedAt = time.Now() // 新版本的创建时间
|
||||
|
||||
query := `
|
||||
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
newID,
|
||||
template.WorkspaceID,
|
||||
template.OwnerID,
|
||||
template.ChartReferenceID,
|
||||
template.Name,
|
||||
template.Description,
|
||||
template.ValuesYAML,
|
||||
template.Version,
|
||||
template.IsDefault,
|
||||
template.CreatedAt,
|
||||
template.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update values template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 Values 模板
|
||||
func (r *ValuesTemplateRepository) Delete(ctx context.Context, id string) error {
|
||||
// 获取模板信息
|
||||
template, err := r.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除该名称的所有版本
|
||||
query := `DELETE FROM values_templates WHERE chart_reference_id = $1 AND name = $2`
|
||||
_, err = r.db.conn.ExecContext(ctx, query, template.ChartReferenceID, template.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete values template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有 Values 模板(管理员用)
|
||||
func (r *ValuesTemplateRepository) List(ctx context.Context) ([]*entity.ValuesTemplate, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
|
||||
FROM values_templates
|
||||
ORDER BY workspace_id, chart_reference_id, name, version DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list values templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanValuesTemplates(rows)
|
||||
}
|
||||
|
||||
// scanValuesTemplates 扫描多行结果
|
||||
func (r *ValuesTemplateRepository) scanValuesTemplates(rows *sql.Rows) ([]*entity.ValuesTemplate, error) {
|
||||
templates := make([]*entity.ValuesTemplate, 0)
|
||||
for rows.Next() {
|
||||
template := &entity.ValuesTemplate{}
|
||||
err := rows.Scan(
|
||||
&template.ID,
|
||||
&template.WorkspaceID,
|
||||
&template.OwnerID,
|
||||
&template.ChartReferenceID,
|
||||
&template.Name,
|
||||
&template.Description,
|
||||
&template.ValuesYAML,
|
||||
&template.Version,
|
||||
&template.IsDefault,
|
||||
&template.CreatedAt,
|
||||
&template.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan values template: %w", err)
|
||||
}
|
||||
templates = append(templates, template)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
}
|
||||
@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@ -11,187 +12,334 @@ import (
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// WorkspaceRepository PostgreSQL Workspace 仓储实现
|
||||
type WorkspaceRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewWorkspaceRepository 创建 PostgreSQL Workspace 仓储
|
||||
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
|
||||
return &WorkspaceRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建 Workspace
|
||||
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
|
||||
if workspace.ID == "" {
|
||||
workspace.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO workspaces (id, name, description, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO workspaces (id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
workspace.ID,
|
||||
workspace.Name,
|
||||
workspace.Description,
|
||||
workspace.Status,
|
||||
workspace.K8sNamespace,
|
||||
workspace.K8sSAName,
|
||||
workspace.DefaultClusterID,
|
||||
workspace.QuotaCPU,
|
||||
workspace.QuotaMemory,
|
||||
workspace.QuotaGPU,
|
||||
workspace.QuotaGPUMem,
|
||||
workspace.CreatedBy,
|
||||
workspace.CreatedAt,
|
||||
workspace.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取 Workspace
|
||||
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||
query := `
|
||||
SELECT id, name, description, created_by, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
workspace := &entity.Workspace{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&workspace.ID,
|
||||
&workspace.Name,
|
||||
&workspace.Description,
|
||||
&workspace.CreatedBy,
|
||||
&workspace.CreatedAt,
|
||||
&workspace.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||
}
|
||||
|
||||
return workspace, nil
|
||||
return r.get(ctx, "id = $1", id)
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取 Workspace
|
||||
func (r *WorkspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||
query := `
|
||||
SELECT id, name, description, created_by, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE name = $1
|
||||
`
|
||||
return r.get(ctx, "name = $1", name)
|
||||
}
|
||||
|
||||
func (r *WorkspaceRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Workspace, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE %s
|
||||
`, where)
|
||||
workspace := &entity.Workspace{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString
|
||||
err := r.db.conn.QueryRowContext(ctx, query, arg).Scan(
|
||||
&workspace.ID,
|
||||
&workspace.Name,
|
||||
&workspace.Description,
|
||||
&workspace.CreatedBy,
|
||||
&workspace.Status,
|
||||
&workspace.K8sNamespace,
|
||||
&workspace.K8sSAName,
|
||||
&defaultClusterID,
|
||||
"aCPU,
|
||||
"aMemory,
|
||||
"aGPU,
|
||||
"aGPUMem,
|
||||
&createdBy,
|
||||
&workspace.CreatedAt,
|
||||
&workspace.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||
}
|
||||
|
||||
workspace.CreatedBy = createdBy.String
|
||||
workspace.DefaultClusterID = defaultClusterID.String
|
||||
workspace.QuotaCPU = quotaCPU.String
|
||||
workspace.QuotaMemory = quotaMemory.String
|
||||
workspace.QuotaGPU = quotaGPU.String
|
||||
workspace.QuotaGPUMem = quotaGPUMem.String
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
// Update 更新 Workspace
|
||||
func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error {
|
||||
workspace.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE workspaces
|
||||
SET name = $1, description = $2, updated_at = $3
|
||||
WHERE id = $4
|
||||
SET name = $1, status = $2, k8s_namespace = $3, k8s_sa_name = $4,
|
||||
default_cluster_id = $5,
|
||||
quota_cpu = $6, quota_memory = $7, quota_gpu = $8, quota_gpu_memory = $9,
|
||||
created_by = $10, updated_at = $11
|
||||
WHERE id = $12
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
workspace.Name,
|
||||
workspace.Description,
|
||||
workspace.Status,
|
||||
workspace.K8sNamespace,
|
||||
workspace.K8sSAName,
|
||||
workspace.DefaultClusterID,
|
||||
workspace.QuotaCPU,
|
||||
workspace.QuotaMemory,
|
||||
workspace.QuotaGPU,
|
||||
workspace.QuotaGPUMem,
|
||||
workspace.CreatedBy,
|
||||
workspace.UpdatedAt,
|
||||
workspace.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update workspace: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrWorkspaceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 Workspace
|
||||
func (r *WorkspaceRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM workspaces WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete workspace: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrWorkspaceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有 Workspace
|
||||
func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
|
||||
query := `
|
||||
SELECT id, name, description, created_by, created_at, updated_at
|
||||
SELECT id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at
|
||||
FROM workspaces
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list workspaces: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
workspaces := make([]*entity.Workspace, 0)
|
||||
for rows.Next() {
|
||||
workspace := &entity.Workspace{}
|
||||
err := rows.Scan(
|
||||
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString
|
||||
if err := rows.Scan(
|
||||
&workspace.ID,
|
||||
&workspace.Name,
|
||||
&workspace.Description,
|
||||
&workspace.CreatedBy,
|
||||
&workspace.Status,
|
||||
&workspace.K8sNamespace,
|
||||
&workspace.K8sSAName,
|
||||
&defaultClusterID,
|
||||
"aCPU,
|
||||
"aMemory,
|
||||
"aGPU,
|
||||
"aGPUMem,
|
||||
&createdBy,
|
||||
&workspace.CreatedAt,
|
||||
&workspace.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan workspace: %w", err)
|
||||
}
|
||||
workspace.CreatedBy = createdBy.String
|
||||
workspace.DefaultClusterID = defaultClusterID.String
|
||||
workspace.QuotaCPU = quotaCPU.String
|
||||
workspace.QuotaMemory = quotaMemory.String
|
||||
workspace.QuotaGPU = quotaGPU.String
|
||||
workspace.QuotaGPUMem = quotaGPUMem.String
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
return workspaces, rows.Err()
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
type WorkspaceClusterBindingRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewWorkspaceClusterBindingRepository(db *DB) repository.WorkspaceClusterBindingRepository {
|
||||
return &WorkspaceClusterBindingRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *WorkspaceClusterBindingRepository) Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error {
|
||||
if binding.ID == "" {
|
||||
binding.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now()
|
||||
if binding.CreatedAt.IsZero() {
|
||||
binding.CreatedAt = now
|
||||
}
|
||||
binding.UpdatedAt = now
|
||||
query := `
|
||||
INSERT INTO workspace_cluster_bindings
|
||||
(id, workspace_id, cluster_id, namespace, service_account, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, status, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT (workspace_id, cluster_id)
|
||||
DO UPDATE SET namespace = EXCLUDED.namespace,
|
||||
service_account = EXCLUDED.service_account,
|
||||
quota_cpu = EXCLUDED.quota_cpu,
|
||||
quota_memory = EXCLUDED.quota_memory,
|
||||
quota_gpu = EXCLUDED.quota_gpu,
|
||||
quota_gpu_memory = EXCLUDED.quota_gpu_memory,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
binding.ID,
|
||||
binding.WorkspaceID,
|
||||
binding.ClusterID,
|
||||
binding.Namespace,
|
||||
binding.ServiceAccount,
|
||||
binding.QuotaCPU,
|
||||
binding.QuotaMemory,
|
||||
binding.QuotaGPU,
|
||||
binding.QuotaGPUMem,
|
||||
binding.Status,
|
||||
binding.CreatedAt,
|
||||
binding.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert workspace cluster binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return workspaces, nil
|
||||
}
|
||||
func (r *WorkspaceClusterBindingRepository) Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, cluster_id, namespace, service_account, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, status, created_at, updated_at
|
||||
FROM workspace_cluster_bindings
|
||||
WHERE workspace_id = $1 AND cluster_id = $2
|
||||
`
|
||||
binding := &entity.WorkspaceClusterBinding{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, clusterID).Scan(
|
||||
&binding.ID,
|
||||
&binding.WorkspaceID,
|
||||
&binding.ClusterID,
|
||||
&binding.Namespace,
|
||||
&binding.ServiceAccount,
|
||||
&binding.QuotaCPU,
|
||||
&binding.QuotaMemory,
|
||||
&binding.QuotaGPU,
|
||||
&binding.QuotaGPUMem,
|
||||
&binding.Status,
|
||||
&binding.CreatedAt,
|
||||
&binding.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace cluster binding: %w", err)
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceClusterBindingRepository) Delete(ctx context.Context, workspaceID, clusterID string) error {
|
||||
_, err := r.db.conn.ExecContext(ctx, `DELETE FROM workspace_cluster_bindings WHERE workspace_id = $1 AND cluster_id = $2`, workspaceID, clusterID)
|
||||
return err
|
||||
}
|
||||
|
||||
type AuditLogRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewAuditLogRepository(db *DB) repository.AuditLogRepository {
|
||||
return &AuditLogRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AuditLogRepository) Create(ctx context.Context, logEntry *entity.AuditLog) error {
|
||||
if logEntry.ID == "" {
|
||||
logEntry.ID = uuid.New().String()
|
||||
}
|
||||
details, err := json.Marshal(logEntry.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal audit details: %w", err)
|
||||
}
|
||||
if logEntry.CreatedAt.IsZero() {
|
||||
logEntry.CreatedAt = time.Now()
|
||||
}
|
||||
query := `
|
||||
INSERT INTO audit_logs (id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
`
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
logEntry.ID,
|
||||
logEntry.WorkspaceID,
|
||||
logEntry.UserID,
|
||||
logEntry.Action,
|
||||
logEntry.ResourceType,
|
||||
logEntry.ResourceID,
|
||||
logEntry.ResourceName,
|
||||
string(details),
|
||||
logEntry.IPAddress,
|
||||
logEntry.UserAgent,
|
||||
logEntry.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audit log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuditLogRepository) ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
query := `
|
||||
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]*entity.AuditLog, 0)
|
||||
for rows.Next() {
|
||||
logEntry := &entity.AuditLog{}
|
||||
var details []byte
|
||||
if err := rows.Scan(
|
||||
&logEntry.ID,
|
||||
&logEntry.WorkspaceID,
|
||||
&logEntry.UserID,
|
||||
&logEntry.Action,
|
||||
&logEntry.ResourceType,
|
||||
&logEntry.ResourceID,
|
||||
&logEntry.ResourceName,
|
||||
&details,
|
||||
&logEntry.IPAddress,
|
||||
&logEntry.UserAgent,
|
||||
&logEntry.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan audit log: %w", err)
|
||||
}
|
||||
_ = json.Unmarshal(details, &logEntry.Details)
|
||||
result = append(result, logEntry)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -21,6 +23,7 @@ type UserSeed struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// RegistrySeed Registry 预注入数据
|
||||
@ -49,8 +52,9 @@ type ClusterSeed struct {
|
||||
//
|
||||
// 加载优先级:
|
||||
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
||||
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
||||
// 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取
|
||||
// 2. 环境变量 BOOTSTRAP_* (root .env / container env)
|
||||
// 3. Mock 模式: 配置文件 config/bootstrap.json
|
||||
// 4. 未提供任何 bootstrap 配置时禁用预注入
|
||||
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||
// 1. 优先从环境变量加载
|
||||
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
||||
@ -61,6 +65,10 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
if config, ok := loadBootstrapConfigFromEnv(); ok {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// 2. 检查适配器模式
|
||||
adapterMode := os.Getenv("ADAPTER_MODE")
|
||||
|
||||
@ -73,7 +81,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// 配置文件不存在,使用默认配置
|
||||
// 配置文件不存在,不预注入任何数据
|
||||
return GetDefaultBootstrapConfig(), nil
|
||||
}
|
||||
|
||||
@ -90,87 +98,142 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// 3. 真实模式 (mode 1, mode 2): 从 .env 读取
|
||||
// 3. 真实模式: 未显式配置时不预注入任何数据
|
||||
return GetDefaultBootstrapConfig(), nil
|
||||
}
|
||||
|
||||
// GetDefaultBootstrapConfig 从 .env 加载 bootstrap 数据。
|
||||
// 支持 BOOTSTRAP_CLUSTERS (逗号分隔的集群名) 以及每个集群的
|
||||
// BOOTSTRAP_CLUSTER_<NAME>_HOST, _CA, _CERT, _KEY, _DESC。
|
||||
// 支持 BOOTSTRAP_REGISTRY_* 环境变量。
|
||||
// 支持 BOOTSTRAP_ADMIN_USER/PASS/EMAIL。
|
||||
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
||||
// Load clusters from .env (comma-separated list of cluster names)
|
||||
clusterStr := os.Getenv("BOOTSTRAP_CLUSTERS")
|
||||
var clusterSeeds []ClusterSeed
|
||||
if clusterStr != "" {
|
||||
clusterNames := strings.Split(clusterStr, ",")
|
||||
for _, name := range clusterNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := sanitizeEnvKey(name)
|
||||
host := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_HOST")
|
||||
ca := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CA")
|
||||
cert := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CERT")
|
||||
keyData := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_KEY")
|
||||
desc := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_DESC")
|
||||
if host != "" {
|
||||
clusterSeeds = append(clusterSeeds, ClusterSeed{
|
||||
Name: name,
|
||||
Host: host,
|
||||
Description: desc,
|
||||
CAData: ca,
|
||||
CertData: cert,
|
||||
KeyData: keyData,
|
||||
})
|
||||
}
|
||||
}
|
||||
func loadBootstrapConfigFromEnv() (*BootstrapConfig, bool) {
|
||||
if !hasBootstrapEnv() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Load registry from .env
|
||||
var registrySeeds []RegistrySeed
|
||||
regName := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_NAME"))
|
||||
regURL := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_URL"))
|
||||
if regName != "" && regURL != "" {
|
||||
registrySeeds = append(registrySeeds, RegistrySeed{
|
||||
Name: regName,
|
||||
URL: regURL,
|
||||
Description: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_DESC")),
|
||||
Username: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_USER")),
|
||||
Password: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_PASS")),
|
||||
Insecure: strings.ToLower(strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_INSECURE"))) == "true",
|
||||
})
|
||||
config := &BootstrapConfig{
|
||||
Enabled: true,
|
||||
Users: make([]UserSeed, 0, 1),
|
||||
Registries: make([]RegistrySeed, 0, 1),
|
||||
Clusters: make([]ClusterSeed, 0),
|
||||
}
|
||||
|
||||
// Load users from .env
|
||||
var userSeeds []UserSeed
|
||||
adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER"))
|
||||
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS"))
|
||||
if adminUser != "" {
|
||||
userSeeds = append(userSeeds, UserSeed{
|
||||
if adminUser != "" && adminPass != "" {
|
||||
config.Users = append(config.Users, UserSeed{
|
||||
Username: adminUser,
|
||||
Password: adminPass,
|
||||
Email: strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_EMAIL")),
|
||||
Email: getEnv("BOOTSTRAP_ADMIN_EMAIL", adminUser+"@example.local"),
|
||||
Role: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
return &BootstrapConfig{
|
||||
Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0,
|
||||
Users: userSeeds,
|
||||
Registries: registrySeeds,
|
||||
Clusters: clusterSeeds,
|
||||
if registryURL := os.Getenv("BOOTSTRAP_REGISTRY_URL"); registryURL != "" {
|
||||
registryUser := getEnv("BOOTSTRAP_REGISTRY_ROBOT_USER", getEnv("BOOTSTRAP_REGISTRY_USER", ""))
|
||||
registryPass := getEnv("BOOTSTRAP_REGISTRY_ROBOT_PASS", getEnv("BOOTSTRAP_REGISTRY_PASS", ""))
|
||||
config.Registries = append(config.Registries, RegistrySeed{
|
||||
Name: getEnv("BOOTSTRAP_REGISTRY_NAME", "harbor"),
|
||||
URL: registryURL,
|
||||
Description: getEnv("BOOTSTRAP_REGISTRY_DESC", ""),
|
||||
Username: registryUser,
|
||||
Password: registryPass,
|
||||
Insecure: parseBoolEnv("BOOTSTRAP_REGISTRY_INSECURE", false),
|
||||
})
|
||||
}
|
||||
|
||||
if parseBoolEnv("BOOTSTRAP_ENABLE_CLUSTERS", false) {
|
||||
for _, clusterName := range discoverBootstrapClusters() {
|
||||
prefix := "BOOTSTRAP_CLUSTER_" + normalizeEnvName(clusterName) + "_"
|
||||
host := os.Getenv(prefix + "HOST")
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
config.Clusters = append(config.Clusters, ClusterSeed{
|
||||
Name: strings.ToLower(clusterName),
|
||||
Host: host,
|
||||
Description: os.Getenv(prefix + "DESC"),
|
||||
CAData: os.Getenv(prefix + "CA"),
|
||||
CertData: os.Getenv(prefix + "CERT"),
|
||||
KeyData: os.Getenv(prefix + "KEY"),
|
||||
Token: os.Getenv(prefix + "TOKEN"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return config, true
|
||||
}
|
||||
|
||||
// sanitizeEnvKey converts "my-cluster" to "MY_CLUSTER" for env var names.
|
||||
func sanitizeEnvKey(name string) string {
|
||||
s := strings.Map(func(r rune) rune {
|
||||
if r == '-' || r == ' ' {
|
||||
return '_'
|
||||
func hasBootstrapEnv() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "BOOTSTRAP_") {
|
||||
return true
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoverBootstrapClusters() []string {
|
||||
names := make(map[string]struct{})
|
||||
|
||||
if configured := os.Getenv("BOOTSTRAP_CLUSTERS"); configured != "" {
|
||||
for _, name := range strings.Split(configured, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
names[normalizeEnvName(name)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, env := range os.Environ() {
|
||||
key, _, ok := strings.Cut(env, "=")
|
||||
if !ok || !strings.HasPrefix(key, "BOOTSTRAP_CLUSTER_") || !strings.HasSuffix(key, "_HOST") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(key, "BOOTSTRAP_CLUSTER_"), "_HOST")
|
||||
if name != "" {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
result = append(result, name)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEnvName(name string) string {
|
||||
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
|
||||
return strings.ToUpper(replacer.Replace(strings.TrimSpace(name)))
|
||||
}
|
||||
|
||||
func parseBoolEnv(key string, defaultValue bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetDefaultBootstrapConfig 返回安全的空默认配置。
|
||||
//
|
||||
// 这里不能包含真实或示例账号密码、Registry 或集群凭据。预注入数据必须来自
|
||||
// BOOTSTRAP_CONFIG_JSON、BOOTSTRAP_* 环境变量,或显式提供的 bootstrap 配置文件。
|
||||
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
||||
return &BootstrapConfig{
|
||||
Enabled: false,
|
||||
Users: []UserSeed{},
|
||||
Registries: []RegistrySeed{},
|
||||
Clusters: []ClusterSeed{},
|
||||
}
|
||||
}
|
||||
|
||||
103
backend/internal/bootstrap/config_test.go
Normal file
103
backend/internal/bootstrap/config_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package bootstrap
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultBootstrapConfigIsEmptyAndDisabled(t *testing.T) {
|
||||
config := GetDefaultBootstrapConfig()
|
||||
if config.Enabled {
|
||||
t.Fatal("default bootstrap config must be disabled")
|
||||
}
|
||||
if len(config.Users) != 0 || len(config.Registries) != 0 || len(config.Clusters) != 0 {
|
||||
t.Fatalf("default bootstrap config must not include seeded data: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBootstrapConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_ADMIN_USER", "root")
|
||||
t.Setenv("BOOTSTRAP_ADMIN_PASS", "secret")
|
||||
t.Setenv("BOOTSTRAP_ADMIN_EMAIL", "root@example.com")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_NAME", "harbor")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_URL", "https://harbor.example.com")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_DESC", "test registry")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_USER", "robot")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_PASS", "robot-secret")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_ROBOT_USER", "robot$ocdp")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_ROBOT_PASS", "robot-token")
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_INSECURE", "true")
|
||||
t.Setenv("BOOTSTRAP_ENABLE_CLUSTERS", "true")
|
||||
t.Setenv("BOOTSTRAP_CLUSTERS", "cluster1,gpu-prod")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_HOST", "https://cluster1.example.com:6443")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_DESC", "cluster one")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_CA", "ca-data")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_CERT", "cert-data")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_KEY", "key-data")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_GPU_PROD_HOST", "https://gpu.example.com:6443")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_GPU_PROD_TOKEN", "bearer-token")
|
||||
|
||||
config, ok := loadBootstrapConfigFromEnv()
|
||||
if !ok {
|
||||
t.Fatal("expected bootstrap config from environment")
|
||||
}
|
||||
|
||||
if len(config.Users) != 1 || config.Users[0].Username != "root" || config.Users[0].Password != "secret" {
|
||||
t.Fatalf("unexpected users: %#v", config.Users)
|
||||
}
|
||||
|
||||
if len(config.Registries) != 1 {
|
||||
t.Fatalf("expected one registry, got %d", len(config.Registries))
|
||||
}
|
||||
registry := config.Registries[0]
|
||||
if registry.Name != "harbor" || registry.URL != "https://harbor.example.com" || !registry.Insecure {
|
||||
t.Fatalf("unexpected registry: %#v", registry)
|
||||
}
|
||||
if registry.Username != "robot$ocdp" || registry.Password != "robot-token" {
|
||||
t.Fatalf("expected robot registry credentials, got %#v", registry)
|
||||
}
|
||||
|
||||
if len(config.Clusters) != 2 {
|
||||
t.Fatalf("expected two clusters, got %d: %#v", len(config.Clusters), config.Clusters)
|
||||
}
|
||||
|
||||
clusterByName := map[string]ClusterSeed{}
|
||||
for _, cluster := range config.Clusters {
|
||||
clusterByName[cluster.Name] = cluster
|
||||
}
|
||||
|
||||
if clusterByName["cluster1"].Host != "https://cluster1.example.com:6443" {
|
||||
t.Fatalf("unexpected cluster1: %#v", clusterByName["cluster1"])
|
||||
}
|
||||
if clusterByName["gpu_prod"].Token != "bearer-token" {
|
||||
t.Fatalf("unexpected gpu_prod: %#v", clusterByName["gpu_prod"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapClustersRequireExplicitEnable(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_ADMIN_USER", "root")
|
||||
t.Setenv("BOOTSTRAP_ADMIN_PASS", "secret")
|
||||
t.Setenv("BOOTSTRAP_CLUSTERS", "cluster1")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_HOST", "https://cluster1.example.com:6443")
|
||||
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_TOKEN", "token")
|
||||
|
||||
config, ok := loadBootstrapConfigFromEnv()
|
||||
if !ok {
|
||||
t.Fatal("expected bootstrap config from environment")
|
||||
}
|
||||
if len(config.Clusters) != 0 {
|
||||
t.Fatalf("bootstrap clusters must be disabled unless BOOTSTRAP_ENABLE_CLUSTERS=true, got %#v", config.Clusters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapEnvDoesNotCreateDefaultAdmin(t *testing.T) {
|
||||
t.Setenv("BOOTSTRAP_REGISTRY_URL", "https://harbor.example.com")
|
||||
|
||||
config, ok := loadBootstrapConfigFromEnv()
|
||||
if !ok {
|
||||
t.Fatal("expected bootstrap config from environment")
|
||||
}
|
||||
if len(config.Users) != 0 {
|
||||
t.Fatalf("expected no users without explicit admin credentials, got %#v", config.Users)
|
||||
}
|
||||
if len(config.Registries) != 1 {
|
||||
t.Fatalf("expected one registry, got %d", len(config.Registries))
|
||||
}
|
||||
}
|
||||
@ -84,6 +84,12 @@ func (s *Seeder) seedUsers(ctx context.Context) error {
|
||||
// 创建用户
|
||||
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
|
||||
user.ID = uuid.New().String()
|
||||
if userSeed.Role != "" {
|
||||
user.Role = userSeed.Role
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
|
||||
if err := s.repos.UserRepo.Create(ctx, user); err != nil {
|
||||
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
|
||||
@ -105,6 +111,7 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
|
||||
|
||||
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
|
||||
|
||||
ownerID := s.bootstrapOwnerID(ctx)
|
||||
for _, registrySeed := range s.config.Registries {
|
||||
// 检查 Registry 是否已存在
|
||||
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
|
||||
@ -117,6 +124,9 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
|
||||
registry := &entity.Registry{
|
||||
ID: uuid.New().String(),
|
||||
Name: registrySeed.Name,
|
||||
WorkspaceID: entity.DefaultWorkspaceID,
|
||||
OwnerID: ownerID,
|
||||
Visibility: "global_shared",
|
||||
URL: registrySeed.URL,
|
||||
Description: registrySeed.Description,
|
||||
Username: registrySeed.Username,
|
||||
@ -146,6 +156,7 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
||||
|
||||
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
|
||||
|
||||
ownerID := s.bootstrapOwnerID(ctx)
|
||||
for _, clusterSeed := range s.config.Clusters {
|
||||
// 检查 Cluster 是否已存在
|
||||
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
|
||||
@ -158,6 +169,9 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
||||
cluster := &entity.Cluster{
|
||||
ID: uuid.New().String(),
|
||||
Name: clusterSeed.Name,
|
||||
WorkspaceID: entity.DefaultWorkspaceID,
|
||||
OwnerID: ownerID,
|
||||
Visibility: "global_shared",
|
||||
Host: clusterSeed.Host,
|
||||
Description: clusterSeed.Description,
|
||||
CAData: clusterSeed.CAData,
|
||||
@ -179,3 +193,22 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Seeder) bootstrapOwnerID(ctx context.Context) string {
|
||||
for _, userSeed := range s.config.Users {
|
||||
if userSeed.Role == "admin" {
|
||||
if user, err := s.repos.UserRepo.GetByUsername(ctx, userSeed.Username); err == nil && user != nil {
|
||||
return user.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
users, err := s.repos.UserRepo.List(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Role == "admin" {
|
||||
return user.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArtifactType Artifact 类型
|
||||
@ -16,16 +16,16 @@ const (
|
||||
|
||||
// Artifact OCI Artifact 领域实体
|
||||
type Artifact struct {
|
||||
RegistryID string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Type ArtifactType
|
||||
Size int64
|
||||
MediaType string
|
||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||
Annotations map[string]string
|
||||
CreatedAt time.Time
|
||||
RegistryID string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Type ArtifactType
|
||||
Size int64
|
||||
MediaType string
|
||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||
Annotations map[string]string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Repository 仓库信息
|
||||
@ -50,34 +50,34 @@ func NewArtifact(registryID, repository, tag, digest string) *Artifact {
|
||||
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
||||
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
||||
func (a *Artifact) SetType(mediaType string) {
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
||||
@ -87,85 +87,84 @@ func (a *Artifact) SetType(mediaType string) {
|
||||
// 3. Repository 名称 - charts/ 前缀暗示
|
||||
// 4. MediaType - 兜底判断
|
||||
func (a *Artifact) DetermineType() {
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||
if a.ConfigType != "" {
|
||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||
|
||||
// Helm Chart 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||
) {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
|
||||
// Docker/OCI Image 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"docker.container.image", "oci.image.config",
|
||||
) {
|
||||
a.Type = ArtifactTypeImage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Annotations
|
||||
for key, value := range a.Annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
|
||||
if containsAny(lowerKey, "helm", "chart") ||
|
||||
containsAny(lowerValue, "helm", "chart") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查 Repository 名称(辅助判断)
|
||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||
// charts/ 开头的仓库很可能是 Helm Chart
|
||||
// 但需要结合 MediaType 进一步确认
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||
if a.ConfigType != "" {
|
||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||
|
||||
// Helm Chart 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||
) {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
|
||||
// Docker/OCI Image 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"docker.container.image", "oci.image.config",
|
||||
) {
|
||||
a.Type = ArtifactTypeImage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Annotations
|
||||
for key, value := range a.Annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
|
||||
if containsAny(lowerKey, "helm", "chart") ||
|
||||
containsAny(lowerValue, "helm", "chart") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查 Repository 名称(辅助判断)
|
||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||
// charts/ 开头的仓库很可能是 Helm Chart
|
||||
// 但需要结合 MediaType 进一步确认
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// IsChart 判断是否为 Helm Chart
|
||||
func (a *Artifact) IsChart() bool {
|
||||
return a.Type == ArtifactTypeChart
|
||||
}
|
||||
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditAction 审计操作类型
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
AuditActionCreate AuditAction = "create"
|
||||
AuditActionUpdate AuditAction = "update"
|
||||
AuditActionDelete AuditAction = "delete"
|
||||
AuditActionDeploy AuditAction = "deploy"
|
||||
AuditActionScale AuditAction = "scale"
|
||||
AuditActionLogin AuditAction = "login"
|
||||
AuditActionLogout AuditAction = "logout"
|
||||
AuditActionChangePassword AuditAction = "change_password"
|
||||
)
|
||||
|
||||
// AuditResourceType 审计资源类型
|
||||
type AuditResourceType string
|
||||
|
||||
const (
|
||||
AuditResourceUser AuditResourceType = "user"
|
||||
AuditResourceWorkspace AuditResourceType = "workspace"
|
||||
AuditResourceQuota AuditResourceType = "quota"
|
||||
AuditResourceCluster AuditResourceType = "cluster"
|
||||
AuditResourceRegistry AuditResourceType = "registry"
|
||||
AuditResourceInstance AuditResourceType = "instance"
|
||||
AuditResourceStorage AuditResourceType = "storage"
|
||||
AuditResourceTemplate AuditResourceType = "template"
|
||||
)
|
||||
|
||||
// AuditLog 审计日志实体
|
||||
type AuditLog struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
UserID string
|
||||
Action AuditAction
|
||||
ResourceType AuditResourceType
|
||||
ResourceID string
|
||||
ResourceName string
|
||||
Details map[string]interface{}
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewAuditLog 创建新审计日志
|
||||
func NewAuditLog(workspaceID, userID string, action AuditAction, resourceType AuditResourceType) *AuditLog {
|
||||
now := time.Now()
|
||||
return &AuditLog{
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: userID,
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetResource 设置关联资源
|
||||
func (a *AuditLog) SetResource(resourceID, resourceName string) {
|
||||
a.ResourceID = resourceID
|
||||
a.ResourceName = resourceName
|
||||
}
|
||||
|
||||
// SetDetails 设置详细信息
|
||||
func (a *AuditLog) SetDetails(details map[string]interface{}) {
|
||||
a.Details = details
|
||||
}
|
||||
|
||||
// SetClientInfo 设置客户端信息
|
||||
func (a *AuditLog) SetClientInfo(ipAddress, userAgent string) {
|
||||
a.IPAddress = ipAddress
|
||||
a.UserAgent = userAgent
|
||||
}
|
||||
|
||||
// DetailsJSON 将详情转为 JSON 字符串
|
||||
func (a *AuditLog) DetailsJSON() (string, error) {
|
||||
if a.Details == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(a.Details)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChartReference Chart 引用实体
|
||||
type ChartReference struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
RegistryID string
|
||||
Repository string
|
||||
ChartName string
|
||||
Description string
|
||||
IsEnabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewChartReference 创建新 Chart 引用
|
||||
func NewChartReference(workspaceID, registryID, repository, chartName, description string) *ChartReference {
|
||||
now := time.Now()
|
||||
return &ChartReference{
|
||||
WorkspaceID: workspaceID,
|
||||
RegistryID: registryID,
|
||||
Repository: repository,
|
||||
ChartName: chartName,
|
||||
Description: description,
|
||||
IsEnabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 验证 Chart 引用数据
|
||||
func (c *ChartReference) Validate() error {
|
||||
if c.Repository == "" {
|
||||
return ErrInvalidChart
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -4,19 +4,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IsolationMode 集群隔离模式
|
||||
type IsolationMode string
|
||||
|
||||
const (
|
||||
IsolationModeNamespace IsolationMode = "namespace" // 共享集群模式,多 workspace 使用不同 namespace
|
||||
IsolationModeCluster IsolationMode = "cluster" // 私有集群模式,每个 workspace 独立集群
|
||||
)
|
||||
|
||||
// Cluster Kubernetes 集群领域实体
|
||||
type Cluster struct {
|
||||
ID string
|
||||
WorkspaceID string // 所属 workspace,NULL 表示全局共享
|
||||
OwnerID string // 创建者用户 ID
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
Visibility string
|
||||
Name string
|
||||
Host string // Kubernetes API Server URL
|
||||
CAData string // Base64 encoded CA certificate
|
||||
@ -24,29 +17,20 @@ type Cluster struct {
|
||||
KeyData string // Base64 encoded client key
|
||||
Token string // Bearer token (alternative to cert auth)
|
||||
Description string
|
||||
|
||||
// 隔离模式
|
||||
IsolationMode IsolationMode // 'namespace' | 'cluster'
|
||||
DefaultNamespace string // 当 isolation_mode=namespace 时的默认 namespace 前缀
|
||||
|
||||
IsShared bool // 是否为共享集群(admin 创建供多 workspace 使用)
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DefaultNamespace string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewCluster 创建新集群
|
||||
func NewCluster(workspaceID, ownerID, name, host string) *Cluster {
|
||||
func NewCluster(name, host string) *Cluster {
|
||||
now := time.Now()
|
||||
return &Cluster{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
Name: name,
|
||||
Host: host,
|
||||
IsolationMode: IsolationModeNamespace, // 默认 namespace 隔离模式
|
||||
DefaultNamespace: workspaceID, // 默认使用 workspace ID 作为 namespace 前缀
|
||||
IsShared: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Name: name,
|
||||
Host: host,
|
||||
Visibility: "private",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,35 +68,14 @@ func (c *Cluster) Validate() error {
|
||||
if c.Host == "" {
|
||||
return ErrInvalidClusterHost
|
||||
}
|
||||
|
||||
// 检查是否有 kubeconfig 格式(完整的 kubeconfig 在 CAData 中)
|
||||
hasKubeconfig := len(c.CAData) > 100 && (c.CAData[:11] == "apiVersion:" || c.CAData[:5] == "kind:")
|
||||
|
||||
// 认证方式:证书、Token、kubeconfig 或空(使用本地 kubeconfig)
|
||||
hasCertAuth := c.CertData != "" && c.KeyData != ""
|
||||
hasToken := c.Token != ""
|
||||
hasNoAuth := c.CertData == "" && c.KeyData == "" && c.Token == ""
|
||||
|
||||
// 如果有 kubeconfig 格式,或有证书,或有 token,或没有凭证(依赖 TestConnection 使用本地 kubeconfig),都是有效的
|
||||
if hasKubeconfig || hasCertAuth || hasToken || hasNoAuth {
|
||||
return nil
|
||||
if c.Visibility == "" {
|
||||
c.Visibility = "private"
|
||||
}
|
||||
|
||||
return ErrInvalidClusterAuth
|
||||
}
|
||||
|
||||
// GetNamespace 获取部署用的 namespace
|
||||
// namespace 隔离模式: {workspace_id}-{instance_name} 或 {default_namespace}-{username}
|
||||
// cluster 隔离模式: 使用 workspace 的默认 namespace
|
||||
func (c *Cluster) GetNamespace(workspaceName, instanceName string) string {
|
||||
if c.IsolationMode == IsolationModeCluster {
|
||||
return c.DefaultNamespace
|
||||
// 必须有认证方式:证书或 Token
|
||||
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
||||
return ErrInvalidClusterAuth
|
||||
}
|
||||
// namespace 隔离模式
|
||||
if c.DefaultNamespace != "" {
|
||||
return c.DefaultNamespace + "-" + instanceName
|
||||
}
|
||||
return workspaceName + "-" + instanceName
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKubeConfig 生成 kubeconfig 内容
|
||||
@ -145,4 +108,3 @@ users:
|
||||
|
||||
return kubeconfig
|
||||
}
|
||||
|
||||
|
||||
@ -5,11 +5,15 @@ import "errors"
|
||||
// 领域错误定义
|
||||
var (
|
||||
// User errors
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
ErrUnauthorized = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("permission denied")
|
||||
ErrUserInactive = errors.New("user is inactive")
|
||||
ErrWorkspaceSuspended = errors.New("workspace is suspended")
|
||||
|
||||
// Cluster errors
|
||||
ErrInvalidClusterName = errors.New("invalid cluster name")
|
||||
@ -37,32 +41,8 @@ var (
|
||||
ErrArtifactNotFound = errors.New("artifact not found")
|
||||
ErrRepositoryNotFound = errors.New("repository not found")
|
||||
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
||||
ErrValuesNotFound = errors.New("values not found")
|
||||
|
||||
// Workspace errors
|
||||
ErrInvalidWorkspaceName = errors.New("invalid workspace name")
|
||||
ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||
ErrWorkspaceExists = errors.New("workspace already exists")
|
||||
|
||||
// Quota errors
|
||||
ErrQuotaExceeded = errors.New("quota exceeded")
|
||||
ErrInvalidQuota = errors.New("invalid quota")
|
||||
|
||||
// Storage errors
|
||||
ErrInvalidStorageName = errors.New("invalid storage name")
|
||||
ErrStorageNotFound = errors.New("storage not found")
|
||||
ErrStorageExists = errors.New("storage already exists")
|
||||
|
||||
// Chart Reference errors
|
||||
ErrInvalidChartReferenceName = errors.New("invalid chart reference name")
|
||||
ErrChartReferenceNotFound = errors.New("chart reference not found")
|
||||
ErrChartReferenceExists = errors.New("chart reference already exists")
|
||||
|
||||
// Template errors
|
||||
ErrInvalidTemplateName = errors.New("invalid template name")
|
||||
ErrTemplateNotFound = errors.New("template not found")
|
||||
ErrTemplateExists = errors.New("template already exists")
|
||||
|
||||
// Permission errors
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||
ErrWorkspaceExists = errors.New("workspace already exists")
|
||||
)
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// InstanceStatus 实例状态
|
||||
@ -38,65 +33,46 @@ const (
|
||||
|
||||
// Instance Helm 应用实例领域实体
|
||||
type Instance struct {
|
||||
ID string
|
||||
WorkspaceID string // 所属 workspace
|
||||
OwnerID string // 创建者用户 ID
|
||||
ClusterID string
|
||||
RegistryID string
|
||||
ChartReferenceID string // 引用的 Chart 引用
|
||||
ValuesTemplateID string // 使用的 Values 模板
|
||||
|
||||
Name string // Helm Release Name
|
||||
Namespace string
|
||||
Repository string // OCI Repository (e.g., charts/app)
|
||||
Chart string // Chart Name
|
||||
Version string // Chart Version
|
||||
Description string
|
||||
Values map[string]interface{} // Helm Values (JSON)
|
||||
ValuesYAML string // Helm Values (YAML format)
|
||||
UserOverrideYAML string // 用户额外覆盖的配置
|
||||
|
||||
Status InstanceStatus
|
||||
StatusReason string
|
||||
LastOperation InstanceOperation
|
||||
LastError string
|
||||
Revision int // Helm Release Revision
|
||||
|
||||
// 资源使用统计(Helm 安装时从集群获取并更新)
|
||||
CPURequested float64 // CPU 请求量 (cores)
|
||||
MemoryRequested string // 内存请求量 (e.g., "2Gi")
|
||||
GPURequested float64 // GPU 请求量 (cards)
|
||||
GPUMemoryRequested string // GPU 内存请求量 (e.g., "16Gi")
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
ClusterID string
|
||||
Name string // Helm Release Name
|
||||
Namespace string
|
||||
RegistryID string
|
||||
Repository string // OCI Repository (e.g., charts/app)
|
||||
Chart string // Chart Name
|
||||
Version string // Chart Version
|
||||
Description string
|
||||
Values map[string]interface{} // Helm Values (JSON)
|
||||
ValuesYAML string // Helm Values (YAML format)
|
||||
Status InstanceStatus
|
||||
StatusReason string
|
||||
LastOperation InstanceOperation
|
||||
LastError string
|
||||
Revision int // Helm Release Revision
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Replicas int // Running K8s replicas (enriched, not persisted)
|
||||
}
|
||||
|
||||
// NewInstance 创建新实例
|
||||
func NewInstance(workspaceID, ownerID, clusterID, registryID, chartReferenceID, valuesTemplateID, name, namespace, repository, chart, version string) *Instance {
|
||||
func NewInstance(clusterID, name, namespace, registryID, repository, chart, version string) *Instance {
|
||||
now := time.Now()
|
||||
return &Instance{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
ClusterID: clusterID,
|
||||
RegistryID: registryID,
|
||||
ChartReferenceID: chartReferenceID,
|
||||
ValuesTemplateID: valuesTemplateID,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Repository: repository,
|
||||
Chart: chart,
|
||||
Version: version,
|
||||
Status: StatusPending,
|
||||
StatusReason: "Pending install",
|
||||
LastOperation: OperationInstall,
|
||||
Revision: 1,
|
||||
CPURequested: 0,
|
||||
MemoryRequested: "0Mi",
|
||||
GPURequested: 0,
|
||||
GPUMemoryRequested: "0Mi",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ClusterID: clusterID,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
RegistryID: registryID,
|
||||
Repository: repository,
|
||||
Chart: chart,
|
||||
Version: version,
|
||||
Status: StatusPending,
|
||||
StatusReason: "Pending install",
|
||||
LastOperation: OperationInstall,
|
||||
Revision: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,31 +82,9 @@ func (i *Instance) SetValues(values map[string]interface{}) {
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetValuesYAML 设置 YAML 格式的 Values 并解析到 Values map
|
||||
func (i *Instance) SetValuesYAML(yamlStr string) {
|
||||
i.ValuesYAML = yamlStr
|
||||
if yamlStr == "" {
|
||||
return
|
||||
}
|
||||
// 解析 YAML 到 map,确保 Helm 客户端能正确使用
|
||||
var parsed map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(yamlStr), &parsed); err != nil {
|
||||
log.Printf("[SetValuesYAML] WARNING: failed to parse YAML for instance %s: %s, yaml=%q", i.Name, err, yamlStr)
|
||||
return
|
||||
}
|
||||
if parsed == nil {
|
||||
return
|
||||
}
|
||||
// Merge into existing Values (user-provided takes precedence)
|
||||
if i.Values == nil {
|
||||
i.Values = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range parsed {
|
||||
// Only set if not already present (Values map takes precedence over YAML fallback)
|
||||
if _, exists := i.Values[k]; !exists {
|
||||
i.Values[k] = v
|
||||
}
|
||||
}
|
||||
// SetValuesYAML 设置 YAML 格式的 Values
|
||||
func (i *Instance) SetValuesYAML(yaml string) {
|
||||
i.ValuesYAML = yaml
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
@ -203,43 +157,13 @@ func (i *Instance) Upgrade(version string, values map[string]interface{}) {
|
||||
i.BeginOperation(OperationUpgrade, "Pending upgrade")
|
||||
}
|
||||
|
||||
// ValidateReleaseName 验证 Helm Release 名称是否符合 RFC 1123 DNS 子域名规范
|
||||
// Helm release 名称必须:
|
||||
// - 只能包含小写字母(a-z)、数字(0-9)和连字符(-)
|
||||
// - 不能以连字符开头或结尾
|
||||
// - 长度不超过 53 个字符
|
||||
func ValidateReleaseName(name string) error {
|
||||
if name == "" {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
|
||||
// 检查长度(RFC 1123 DNS 子域名最大长度为 63,但 Helm 限制为 53)
|
||||
if len(name) > 53 {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
|
||||
// 不能以连字符开头或结尾
|
||||
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
|
||||
// 只能包含小写字母、数字和连字符
|
||||
for _, r := range name {
|
||||
if !(unicode.IsLower(r) || unicode.IsDigit(r) || r == '-') {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 验证实例配置
|
||||
func (i *Instance) Validate() error {
|
||||
if i.ClusterID == "" {
|
||||
return ErrInvalidClusterID
|
||||
}
|
||||
if err := ValidateReleaseName(i.Name); err != nil {
|
||||
return err
|
||||
if i.Name == "" {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
if i.Namespace == "" {
|
||||
return ErrInvalidNamespace
|
||||
|
||||
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
@ -0,0 +1,70 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type InstanceDiagnostics struct {
|
||||
InstanceName string
|
||||
Namespace string
|
||||
Pods []InstancePodDiagnostics
|
||||
Services []InstanceServiceDiagnostics
|
||||
Events []InstanceEventDiagnostics
|
||||
Logs []InstancePodLog
|
||||
CollectedAt time.Time
|
||||
}
|
||||
|
||||
type InstancePodDiagnostics struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Phase string
|
||||
NodeName string
|
||||
PodIP string
|
||||
HostIP string
|
||||
RestartCount int32
|
||||
Containers []InstanceContainerDiagnostics
|
||||
Conditions []InstanceConditionDiagnostics
|
||||
CreationTimestamp time.Time
|
||||
}
|
||||
|
||||
type InstanceContainerDiagnostics struct {
|
||||
Name string
|
||||
Image string
|
||||
Ready bool
|
||||
RestartCount int32
|
||||
State string
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
type InstanceConditionDiagnostics struct {
|
||||
Type string
|
||||
Status string
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
type InstanceServiceDiagnostics struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Type string
|
||||
ClusterIP string
|
||||
Ports []InstanceEntryPort
|
||||
}
|
||||
|
||||
type InstanceEventDiagnostics struct {
|
||||
Type string
|
||||
Reason string
|
||||
Message string
|
||||
InvolvedKind string
|
||||
InvolvedName string
|
||||
Count int32
|
||||
FirstTimestamp time.Time
|
||||
LastTimestamp time.Time
|
||||
}
|
||||
|
||||
type InstancePodLog struct {
|
||||
Pod string
|
||||
Container string
|
||||
TailLines int64
|
||||
Log string
|
||||
Error string
|
||||
}
|
||||
@ -4,70 +4,70 @@ import "time"
|
||||
|
||||
// ClusterMetrics 集群监控指标
|
||||
type ClusterMetrics struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Status string `json:"status"` // healthy, warning, error, unknown
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"node_count"`
|
||||
PodCount int `json:"pod_count"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Status string `json:"status"` // healthy, warning, error, unknown
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"node_count"`
|
||||
PodCount int `json:"pod_count"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
|
||||
// 集群级别资源汇总
|
||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||
|
||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||
|
||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||
|
||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||
|
||||
// 单机资源最大值
|
||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||
|
||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||
|
||||
// 节点列表(简化信息)
|
||||
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMetrics 节点监控指标
|
||||
type NodeMetrics struct {
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Ready, NotReady
|
||||
Role string `json:"role"` // control-plane, worker
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"pod_count"`
|
||||
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Ready, NotReady
|
||||
Role string `json:"role"` // control-plane, worker
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"pod_count"`
|
||||
|
||||
// CPU 资源
|
||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||
CPUAllocatable string `json:"cpu_allocatable"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||
CPUAllocatable string `json:"cpu_allocatable"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
|
||||
|
||||
// 内存资源
|
||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||
MemoryAllocatable string `json:"memory_allocatable"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
|
||||
|
||||
// GPU 资源(如果有)
|
||||
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
||||
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
||||
GPUPercent float64 `json:"gpu_percent"`
|
||||
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
||||
|
||||
|
||||
// 其他信息
|
||||
OSImage string `json:"os_image,omitempty"`
|
||||
KernelVersion string `json:"kernel_version,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||
OSImage string `json:"os_image,omitempty"`
|
||||
KernelVersion string `json:"kernel_version,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||
}
|
||||
|
||||
// MonitoringSummary 监控汇总
|
||||
@ -80,4 +80,3 @@ type MonitoringSummary struct {
|
||||
TotalPods int `json:"total_pods"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResourceType 资源类型
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
ResourceCPU ResourceType = "cpu"
|
||||
ResourceGPU ResourceType = "gpu"
|
||||
ResourceGPUMemory ResourceType = "gpu_memory"
|
||||
)
|
||||
|
||||
// WorkspaceQuota 工作空间配额实体
|
||||
type WorkspaceQuota struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
ResourceType ResourceType
|
||||
HardLimit float64 // 硬限制(0表示无限制)
|
||||
SoftLimit float64 // 软限制(警告阈值)
|
||||
Used float64 // 当前使用量
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewWorkspaceQuota 创建新配额
|
||||
func NewWorkspaceQuota(workspaceID string, resourceType ResourceType, hardLimit, softLimit float64) *WorkspaceQuota {
|
||||
now := time.Now()
|
||||
return &WorkspaceQuota{
|
||||
WorkspaceID: workspaceID,
|
||||
ResourceType: resourceType,
|
||||
HardLimit: hardLimit,
|
||||
SoftLimit: softLimit,
|
||||
Used: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// CanAllocate 检查是否可以分配指定资源量
|
||||
func (q *WorkspaceQuota) CanAllocate(amount float64) bool {
|
||||
if q.HardLimit == 0 {
|
||||
return true // 无限制
|
||||
}
|
||||
return q.Used+amount <= q.HardLimit
|
||||
}
|
||||
|
||||
// Allocate 分配资源
|
||||
func (q *WorkspaceQuota) Allocate(amount float64) {
|
||||
q.Used += amount
|
||||
q.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Release 释放资源
|
||||
func (q *WorkspaceQuota) Release(amount float64) {
|
||||
q.Used -= amount
|
||||
if q.Used < 0 {
|
||||
q.Used = 0
|
||||
}
|
||||
q.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// IsOverLimit 检查是否超过硬限制
|
||||
func (q *WorkspaceQuota) IsOverLimit() bool {
|
||||
if q.HardLimit == 0 {
|
||||
return false
|
||||
}
|
||||
return q.Used > q.HardLimit
|
||||
}
|
||||
|
||||
// IsOverSoftLimit 检查是否超过软限制
|
||||
func (q *WorkspaceQuota) IsOverSoftLimit() bool {
|
||||
if q.SoftLimit == 0 {
|
||||
return false
|
||||
}
|
||||
return q.Used > q.SoftLimit
|
||||
}
|
||||
@ -7,30 +7,28 @@ import (
|
||||
// Registry OCI Registry 领域实体
|
||||
type Registry struct {
|
||||
ID string
|
||||
WorkspaceID string // 所属 workspace,NULL 表示全局共享
|
||||
OwnerID string // 创建者用户 ID
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
Visibility string
|
||||
Name string
|
||||
URL string
|
||||
Description string
|
||||
Username string
|
||||
Password string
|
||||
Insecure bool // 是否跳过 TLS 验证
|
||||
IsShared bool // 是否为共享 Registry(admin 创建供多 workspace 使用)
|
||||
Insecure bool // 是否跳过 TLS 验证
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewRegistry 创建新 Registry
|
||||
func NewRegistry(workspaceID, ownerID, name, url string) *Registry {
|
||||
func NewRegistry(name, url string) *Registry {
|
||||
now := time.Now()
|
||||
return &Registry{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
Name: name,
|
||||
URL: url,
|
||||
IsShared: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Name: name,
|
||||
URL: url,
|
||||
Visibility: "private",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +59,8 @@ func (r *Registry) Validate() error {
|
||||
if r.URL == "" {
|
||||
return ErrInvalidRegistryURL
|
||||
}
|
||||
if r.Visibility == "" {
|
||||
r.Visibility = "private"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user