5 Commits
v1.1.0 ... ivan

Author SHA1 Message Date
acee825b14 [Pending]: pending the develop in this way for now 2026-05-07 09:39:02 +08:00
47849042a7 feat: complete E2E deployment flow with storage layered config and values template versioning
- Instance deployment: charts browser, deploy modal, instances list
- Values Template version management (create/history/rollback)
- Storage layered config (cluster > workspace > shared priority)
- Cluster credential decryptIfNeeded for mixed encrypted/plaintext kubeconfig
- YAML syntax validation (client-side + server-side warning)
- Frontend: charts, instances, storage, templates, admin pages
- Backend: storage service, instance service, cluster service, helm client
- Multi-Tenant Kubeconfig.md: added by user
2026-04-30 16:31:00 +08:00
985369d40f fix: resolve deployment API errors and enable E2E deployment flow
Backend fixes:
- instance_dto: add Version field with Normalize() to support both 'version'
  and 'tag' field names from frontend
- instance_handler: add version empty validation before creating instance
- authz.go: fix unused variable compilation error
- registry_repository: fix GetByID/GetByName to use correct DB schema
  (add workspace_id, owner_id, is_shared fields); decrypt password
  gracefully when encryption key mismatches instead of returning error

Frontend:
- charts/page: add Template and Storage dropdown selectors to Deploy Modal

Testing:
- add e2e_test.py: 5-step Playwright E2E test (admin login → create
  workspace → create user → user login → deploy chart)
- add tasks/lesson.md: document 4 bug root causes and fixes
- add tasks/todo.md: track implementation progress
- add PLAN_E2E_DEPLOYMENT.md: comprehensive implementation plan

Verification: confirmed deployment creates instance with status=deployed,
chart downloads from Harbor OCI to /tmp/charts/, Helm release deploys to K8s
2026-04-16 18:39:23 +08:00
ef961d4ade fix: add CORS support and nginx proxy configuration for API requests
- Add localhost to ALLOWED_DEV_ORIGINS in backend/docker-compose.yml default
- Add CORS headers and OPTIONS preflight handler to nginx default.conf
- Fix CSP header for API location block
2026-04-15 17:18:43 +08:00
29d0310f03 feat(frontend): add Helm chart browser, monitoring, chart-references and values templates pages
Add new frontend pages for the multi-tenant OCDP platform:

- Charts page (/charts): Browse Harbor OCI registries to list Helm chart repositories
  and versions, with deploy modal to launch charts on selected clusters
- Monitoring page (/monitoring): Display cluster metrics (CPU/Memory/GPU usage)
  and per-node details with resource utilization bars
- Chart References page (/chart-references): CRUD for chart metadata references
- Values Templates page (/templates): CRUD for Helm values templates with version
  history and rollback support
- Sidebar: Add Charts navigation, update Storage and Templates links
- api.ts: Add all API client functions (clusterApi, registryApi, instanceApi,
  monitoringApi, storageApi, chartReferenceApi, valuesTemplateApi,
  workspaceApi, userApi) with full TypeScript types

Note: deploy flow and values template rollback not yet end-to-end tested.
2026-04-15 16:59:31 +08:00
354 changed files with 28067 additions and 48380 deletions

View File

@ -1,79 +0,0 @@
# 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
View File

@ -36,6 +36,9 @@ build/
backend/bin/ backend/bin/
frontend/dist/ frontend/dist/
# Compiled binaries
backend/ocdp-backend
# Logs # Logs
*.log *.log
logs/ logs/
@ -60,19 +63,18 @@ redis_data/
tmp/ tmp/
temp/ temp/
*.tmp *.tmp
.fuse_hidden*
# Debug scripts # Next.js stale build caches
frontend/.next.stale*/
# Debug/temp scripts
debug_*.py debug_*.py
test_*.py test_*.py
# Next.js build output (including stale caches) # Kubeconfig (contains sensitive credentials)
frontend/.next*/ *.kubeconfig
frontend/next-env.d.ts kubeconfig
# Compiled binary # AI model output / context storage
backend/ocdp-backend
# IDE / AI temp
.claude/ .claude/

View File

@ -1,47 +0,0 @@
# 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)
- 遇到极其复杂的问题时,不要试图在一个终端窗口内硬扛。
- 拆解子任务,主动进行探索性研究,针对焦点问题逐一击破。

View File

@ -8,7 +8,6 @@
## . 核心原则 (Core Principles) ## . 核心原则 (Core Principles)
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。 1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky在掌握全局上下文后用优雅的方式重构它但不要过度设计 2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky在掌握全局上下文后用优雅的方式重构它但不要过度设计
3. **Test-Driven Quality (测试驱动质量):** 在项目根目录维护 `test/` 文件夹,存放结构化测试脚本。每个脚本顶部必须用注释注明其覆盖的功能范围。当代码发生重大变更时,必须执行 `test/` 下所有相关测试脚本并确保通过,方可视为任务完成。
## Ⅱ. 任务管理闭环 (Task Management Protocol) ## Ⅱ. 任务管理闭环 (Task Management Protocol)
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态: 你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:

View File

@ -1,543 +0,0 @@
# 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
View File

@ -1,68 +1,192 @@
# ============================================================ # ============================================================
# OCDP root orchestration Makefile # OCDP - Open Cloud Development Platform
# Makefile for Docker Compose deployment
# ============================================================ # ============================================================
SHELL := /bin/bash SHELL := /bin/bash
COMPOSE_BIN ?= docker compose # ============================================================
ROOT_COMPOSE := docker-compose.yml # Configuration - Modify these for your environment
COMPOSE := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) # ============================================================
.PHONY: help install run-2 clean-2 docker-dev docker-prod docker-up docker-down docker-logs docker-ps test # Server IP for external access (客户端访问IP)
SERVER_IP ?= 10.6.80.114
.DEFAULT_GOAL := help # 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
# ============================================================
help: help:
@echo "OCDP - Open Cloud Deployment Platform"
@echo "" @echo ""
@echo "OCDP commands" @echo "Usage: make [target]"
@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 ""
@echo "Default local ports: web=18080, https=18443, backend=18081, postgres=15432" @echo "Main Commands:"
@echo "Override with WEB_HTTP_PORT / WEB_HTTPS_PORT / BACKEND_PORT / POSTGRES_PORT." @echo " make up - 启动所有服务"
@echo " make down - 停止所有服务(保留数据)"
@echo " make restart - 重启所有服务"
@echo " make clean - 完全清理(删除所有数据)"
@echo " make rebuild - 强制重建并启动"
@echo "" @echo ""
@echo "Build Commands:"
install: @echo " make build - 构建所有镜像"
@echo "→ Downloading backend modules" @echo " make build-frontend - 只构建前端"
@cd backend && go mod download @echo " make build-backend - 只构建后端"
@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 ""
@$(COMPOSE) ps @echo "Log Commands:"
@echo " make logs - 查看所有日志"
@echo " make logs-backend - 只看后端日志"
@echo " make logs-frontend - 只看前端日志"
@echo " make logs-nginx - 只看nginx日志"
@echo "" @echo ""
@echo "Web: http://localhost:$${WEB_HTTP_PORT:-18080}" @echo "Database Commands:"
@echo "Backend: http://localhost:$${BACKEND_PORT:-18081}/health" @echo " make db-reset - 重置数据库"
@echo " make db-init - 初始化数据库"
docker-dev: run-2 @echo " make db-shell - 进入数据库终端"
@echo ""
docker-prod: run-2 @echo "Utility Commands:"
@echo " make status - 查看服务状态"
docker-up: run-2 @echo ""
@echo "Environment Variables:"
docker-down: @echo " SERVER_IP=$(SERVER_IP) - 服务器IP默认: 10.6.80.114"
@$(COMPOSE) down --remove-orphans @echo " BACKEND_PORT=$(BACKEND_PORT) - 后端端口(默认: 8080"
@echo " ALLOWED_ORIGINS=$(ALLOWED_ORIGINS) - 允许的跨域来源"
clean-2: @echo ""
@$(COMPOSE) down -v --remove-orphans @echo "Examples:"
@echo " make up SERVER_IP=192.168.1.100 # 自定义IP启动"
docker-logs: @echo " make clean # 完全清理并重新开始"
@$(COMPOSE) logs -f @echo "============================================"
docker-ps:
@$(COMPOSE) ps
test:
@test/readme-deployment-refresh.sh

311
PLAN_E2E_DEPLOYMENT.md Normal file
View File

@ -0,0 +1,311 @@
# 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` | 登录调试脚本 |

669
PROJECT_SUMMARY.md Normal file
View File

@ -0,0 +1,669 @@
# 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*

View File

@ -1,413 +0,0 @@
# 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! 🚀

412
README.md
View File

@ -1,267 +1,201 @@
# OCDP - Open Cloud Deployment Platform # OCDP - Open Cloud Deployment Platform
OCDP 是一个面向 Kubernetes 的大模型推理部署平台。当前核心场景是:用户在页面选择 Harbor 中的 `vllm-serve` Helm Chart填写实例名称、命名空间和 values 后,后端从 Harbor 拉取封装好的 OCI Helm Chart并通过 Helm SDK 部署到已配置好的 Kubernetes 集群。 开源云原生部署平台,支持从 Harbor或其他 OCI Registry拉取 Helm Charts 并一键部署到多个 Kubernetes 集群。
## 当前能力 ## 功能特性
- Registry 管理:保存 Harbor / OCI Registry 地址与凭据,敏感字段加密入库。 - **多集群管理** - 支持多个 kubeconfig管理多个 K8S 集群
- Artifact 浏览:通过 Harbor v2.0 API 浏览当前凭据可见的项目、repositories 和 chart tags避免依赖 `/v2/_catalog` 全局 catalog 权限。 - **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
- 一键部署:从前端发起实例创建,后端拉取 Chart 并在目标集群执行 Helm install/upgrade/uninstall。 - **多租户支持** - Workspace 隔离,管理员和普通用户角色
- 集群管理:保存 Kubernetes API Server、CA、客户端证书或 token用于后端连接集群。 - **存储后端** - NFS/PV/hostPath 存储配置管理
- 实例管理查看部署状态、Helm revision、Service/Ingress 入口信息。 - **Chart 引用** - 管理可用的 Helm Charts
- 认证:内置 JWT 登录,首次启动可通过 bootstrap 注入管理员账号。 - **Values 模板** - 版本控制、支持回滚的配置模板
- **一键部署** - 从 Harbor 拉取 Charts 部署到指定集群
- **实例管理** - 支持升级、回滚、卸载 Helm Release
- **状态监控** - 实时同步 Helm Release 状态
## 技术栈 ## 技术栈
- 后端Go 1.24Gorilla MuxHexagonal ArchitecturePostgreSQLORAS SDKHelm SDKKubernetes client-go。 | 层级 | 技术 |
- 前端React 18TypeScriptViteTailwindCSS。 |------|------|
- 部署Docker ComposeNginx 静态文件与 `/api` 反向代理PostgreSQL 持久化。 | 后端 | Go 1.21+, Hexagonal Architecture |
| 前端 | React 18, TypeScript, Next.js, TailwindCSS |
| 数据库 | PostgreSQL |
| 网关 | Nginx |
## 项目结构 ## 快速开始
```text ### Docker Compose 启动(推荐)
ocdp-go/
├── 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 工作记录
```
## 后端部署链路
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 操作目标集群。
## 部署前准备
需要本机已安装:
- 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可配置 TOKENCERT/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 ```bash
# 1. 在根目录检查 .env # 1. 完全停止并清理现有容器
ls .env docker compose -f docker-compose.yml -f backend/docker-compose.yml down -v
# 2. 可选:安装本地依赖。只部署 Docker 栈时不是必须,但这个命令可用。 # 2. 启动所有服务PostgreSQL + Backend + Frontend + Nginx
make install 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. 如果默认高位端口仍被其他项目占用,再临时换端口 # 3. 查看服务状态
export WEB_HTTP_PORT=18080 docker ps
export WEB_HTTPS_PORT=18443
export BACKEND_PORT=18081
export POSTGRES_PORT=15432
# 4. 构建并后台启动完整栈 # 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 run-2
# 兼容旧文档,也可以执行: # 停止服务
make docker-dev
make docker-prod
# 5. 查看服务
make docker-ps
```
访问地址:
- 前端入口http://localhost:${WEB_HTTP_PORT:-18080}
- 后端健康检查http://localhost:${BACKEND_PORT:-18081}/health
- Swagger UIhttp://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 表单或 YAMLYAML 会在前端校验,并由后端解析为 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 make clean-2
``` ```
## 本地开发与测试 ## 配置
后端: ### 环境变量
```bash | 变量 | 默认值 | 说明 |
cd backend |------|--------|------|
go test ./... | DATABASE_URL | postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable | 数据库连接串 |
go run cmd/api/main.go | 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`
## 项目结构
```
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 # 构建命令
``` ```
前端: ## 核心 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 | 实例部署管理 |
## 权限模型
- **Admin** - 管理员,可管理所有资源和用户
- **User** - 普通用户,仅可访问所属 Workspace 的资源
## 开发命令
```bash ```bash
cd frontend # 启动开发服务器
npm ci make dev # 同时启动前后端
npm run build make dev-backend # 仅后端
make dev-frontend # 仅前端
# 数据库操作
make db-init # 初始化数据库
make db-reset # 重置数据库
make db-shell # 打开数据库 shell
# Docker 构建
make build # 构建所有镜像
make build-backend # 构建后端镜像
make build-frontend # 构建前端镜像
# 日志和调试
make logs # 查看所有日志
make logs-backend # 后端日志
make stop # 停止开发服务器
``` ```
Mock 后端仍可通过 `backend/docker-compose.yml``mock` profile 启动: ## License
```bash MIT
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`

View File

@ -1,127 +0,0 @@
# 启动和更新后端服务指南
## 方式一:使用 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
```

View File

@ -1,355 +0,0 @@
# 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
```
**访问地址**
- pgAdminhttp://localhost:5050
- Swagger UIhttp://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>

View File

@ -1,452 +0,0 @@
# 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

View File

@ -1,324 +0,0 @@
# 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 文档始终与代码保持同步!✨

View File

@ -1,223 +0,0 @@
# 开发环境快速指南
## 📋 前置要求
- 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 # 模式 2postgres + 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 # 清理生产环境
```

View File

@ -4,17 +4,12 @@
# ================================================== # ==================================================
FROM golang:1.24-alpine AS builder 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 RUN apk add --no-cache git make
WORKDIR /build WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
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' RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go

View File

@ -1,133 +0,0 @@
# 快速参考卡片
## 🚀 五个核心命令
```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 文件。**

View File

@ -1,343 +1,89 @@
# OCDP Backend # OCDP Backend
基于 Go 的 Kubernetes Helm Chart 管理服务后端,提供完整的制品浏览和应用部署能力。 Go 后端服务,提供 Kubernetes Helm Chart 部署能力。
## ✨ 特性 ## 技术栈
- 🎪 **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
# 安装 Air首次
go install github.com/air-verse/air@latest
# 启动 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
| 分类 | 端点 | 说明 |
|------|------|------|
| **认证** | `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)
## 🔧 开发
### 环境要求
- Go 1.21+ - Go 1.21+
- PostgreSQL 15+ (生产模式) - gorilla/mux (HTTP 路由)
- Docker & Docker Compose (可选) - ORAS Go SDK v2 (OCI 操作)
- Helm SDK (Helm 操作)
- Kubernetes client-go
- PostgreSQL
### 常用命令 ## 启动
```bash ```bash
# 查看所有命令 # Mock 模式(无需数据库,无需外部服务)
make help ADAPTER_MODE=mock go run cmd/api/main.go
# 开发 # 生产模式(需要 PostgreSQL + K8s/Harbor 连接验证)
make dev # 开发模式(热重载) # 启动 PostgreSQL
make build # 构建 docker compose up -d postgres
make run-mock # Mock 模式运行
make run-prod # Production 模式运行
# Docker Compose # 启动后端(生产模式)
make mock # Mock 模式 cd backend
make prod # 生产模式 export DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable"
make logs # 查看日志 export JWT_SECRET="your-jwt-secret"
make status # 查看状态 export ENCRYPTION_KEY="your-32-byte-encryption-key"
make stop # 停止服务 export PORT=8081
export ADAPTER_MODE=production
export KUBECONFIG=/home/ivanwu/.kube/config # 或你的 kubeconfig 路径
# 数据库 # Harbor 凭证(可选,用于验证 Registry 连接)
make db-up # 启动数据库 export HARBOR_URL="https://harbor.bwgdi.com"
make db-psql # 连接数据库 export HARBOR_USERNAME="your-harbor-user"
make db-backup # 备份数据库 export HARBOR_PASSWORD="your-harbor-password"
make pgadmin # 启动 pgAdmin
# NFS 配置(可选)
export NFS_SERVER="10.6.80.11"
export NFS_SHARE="/volume1/NFS"
go run cmd/api/main.go
``` ```
### 项目结构 ## 环境变量说明
| 变量 | 必需 | 说明 |
|------|------|------|
| 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 密码 |
## 连接验证
在生产模式下,创建 Cluster 或 Registry 时会自动验证连接:
- **Cluster**: 尝试使用提供的凭证连接 K8s API Server
- **Registry**: 尝试使用提供的凭证登录 Harbor
如果验证失败,创建会返回错误信息。
## API 访问
- API: http://localhost:8081/api/v1
- Health: http://localhost:8081/health
- Swagger: http://localhost:8081/api/docs
## 项目结构
``` ```
backend/ backend/
├── cmd/api/ # 程序入口 ├── cmd/api/ # 入口
├── internal/ ├── internal/
│ ├── domain/ # 🎯 领域层(核心) │ ├── domain/ # 领域层
│ │ ├── entity/ # 实体 │ │ ├── entity/ # 实体
│ │ ├── service/ # 业务逻辑 │ │ ├── service/ # 业务逻辑
│ │ └── repository/ # 接口定义 │ │ └── repository/ # 接口
│ ├── adapter/ │ ├── adapter/
│ │ ├── input/http/ # 📥 REST API │ │ ├── input/http/ # REST API
│ │ └── output/ # 📤 数据库、OCI、Helm │ │ └── output/ # 数据库、OCI、Helm
── bootstrap/ # Bootstrap 预注入 ── bootstrap/ # 启动配置
│ └── pkg/ # 🔧 工具包 └── docs/ # OpenAPI 规范
├── 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)

View File

@ -1,283 +0,0 @@
# 项目实现审查报告
## ✅ 要求对照检查
### 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)
**可以直接使用!** 🚀

View File

@ -1,400 +0,0 @@
# 测试报告
**测试日期**: 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**: 已安装
---
**测试结论**: 🎉 **所有功能正常,可以投入使用!**

View File

@ -34,10 +34,8 @@ import (
"github.com/ocdp/cluster-service/internal/adapter/input/http/rest" "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"
"github.com/ocdp/cluster-service/internal/adapter/output/k8s"
"github.com/ocdp/cluster-service/internal/bootstrap" "github.com/ocdp/cluster-service/internal/bootstrap"
"github.com/ocdp/cluster-service/internal/domain/service" "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/crypto"
"github.com/ocdp/cluster-service/internal/pkg/jwt" "github.com/ocdp/cluster-service/internal/pkg/jwt"
"github.com/ocdp/cluster-service/internal/pkg/password" "github.com/ocdp/cluster-service/internal/pkg/password"
@ -75,7 +73,6 @@ func main() {
// ===== 5. 创建 Domain Services ===== // ===== 5. 创建 Domain Services =====
authService := service.NewAuthService( authService := service.NewAuthService(
repos.UserRepo, repos.UserRepo,
repos.WorkspaceRepo,
passwordHasher, passwordHasher,
tokenGenerator, tokenGenerator,
) )
@ -101,23 +98,25 @@ func main() {
repos.HelmClient, repos.HelmClient,
repos.OCIClient, repos.OCIClient,
repos.EntryClient, repos.EntryClient,
repos.BindingRepo,
) )
instanceService.SetDiagnosticsClient(repos.DiagnosticsClient)
instanceService.SetTenantProvisioning(repos.WorkspaceRepo, repos.TenantKubeClient)
instanceService.SetScaleClient(k8s.NewScaleClient())
monitoringService := service.NewMonitoringService( monitoringService := service.NewMonitoringService(
repos.ClusterRepo, repos.ClusterRepo,
repos.MetricsClient, repos.MetricsClient,
) )
// Workspace Service
workspaceService := service.NewWorkspaceService( workspaceService := service.NewWorkspaceService(
repos.WorkspaceRepo, repos.WorkspaceRepo,
repos.BindingRepo, repos.QuotaRepo,
repos.ClusterRepo, repos.UserRepo,
repos.TenantKubeClient, )
repos.AuditRepo,
// User Management Service
userManagementService := service.NewUserManagementService(
repos.UserRepo,
repos.WorkspaceRepo,
passwordHasher,
) )
log.Println("✅ Domain Services initialized") log.Println("✅ Domain Services initialized")
@ -126,7 +125,7 @@ func main() {
bootstrapConfig, err := bootstrap.LoadBootstrapConfig() bootstrapConfig, err := bootstrap.LoadBootstrapConfig()
if err != nil { if err != nil {
log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err) log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err)
// 使用安全的空配置,避免在配置错误时写入任何预置账号或集群凭据。 // 使用默认配置
bootstrapConfig = bootstrap.GetDefaultBootstrapConfig() bootstrapConfig = bootstrap.GetDefaultBootstrapConfig()
} }
@ -142,22 +141,51 @@ func main() {
artifactHandler := rest.NewArtifactHandler(artifactService) artifactHandler := rest.NewArtifactHandler(artifactService)
instanceHandler := rest.NewInstanceHandler(instanceService) instanceHandler := rest.NewInstanceHandler(instanceService)
monitoringHandler := rest.NewMonitoringHandler(monitoringService) monitoringHandler := rest.NewMonitoringHandler(monitoringService)
workspaceHandler := rest.NewWorkspaceHandler(workspaceService)
swaggerHandler := rest.NewSwaggerHandler() 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") log.Println("✅ Input Adapters (REST handlers) initialized")
// ===== 8. 设置路由 ===== // ===== 8. 设置路由 =====
router := setupRouter( router := setupRouter(
authHandler, authHandler,
authService,
clusterHandler, clusterHandler,
registryHandler, registryHandler,
artifactHandler, artifactHandler,
instanceHandler, instanceHandler,
monitoringHandler, monitoringHandler,
workspaceHandler,
swaggerHandler, swaggerHandler,
workspaceHandler,
userManagementHandler,
userHandler,
storageHandler,
chartRefHandler,
valuesTemplateHandler,
tokenGenerator,
config.AllowedOrigins,
) )
// ===== 9. 启动服务器 ===== // ===== 9. 启动服务器 =====
@ -180,21 +208,28 @@ func main() {
// Config 应用配置 // Config 应用配置
type Config struct { type Config struct {
AdapterMode string AdapterMode string
Port string Port string
JWTSecret string JWTSecret string
EncryptionKey string EncryptionKey string
DatabaseURL string DatabaseURL string
AllowedOrigins []string
} }
// loadConfig 加载配置 // loadConfig 加载配置
func loadConfig() *Config { func loadConfig() *Config {
allowedOrigins := getEnv("ALLOWED_DEV_ORIGINS", "")
var origins []string
if allowedOrigins != "" {
origins = strings.Split(allowedOrigins, ",")
}
return &Config{ return &Config{
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式) AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"), JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"), EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
DatabaseURL: getEnv("DATABASE_URL", ""), DatabaseURL: getEnv("DATABASE_URL", ""),
AllowedOrigins: origins,
} }
} }
@ -210,20 +245,72 @@ func getEnv(key, defaultValue string) string {
// setupRouter 设置路由 // setupRouter 设置路由
func setupRouter( func setupRouter(
authHandler *rest.AuthHandler, authHandler *rest.AuthHandler,
authService *service.AuthService,
clusterHandler *rest.ClusterHandler, clusterHandler *rest.ClusterHandler,
registryHandler *rest.RegistryHandler, registryHandler *rest.RegistryHandler,
artifactHandler *rest.ArtifactHandler, artifactHandler *rest.ArtifactHandler,
instanceHandler *rest.InstanceHandler, instanceHandler *rest.InstanceHandler,
monitoringHandler *rest.MonitoringHandler, monitoringHandler *rest.MonitoringHandler,
workspaceHandler *rest.WorkspaceHandler,
swaggerHandler *rest.SwaggerHandler, 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,
) *mux.Router { ) *mux.Router {
router := mux.NewRouter().StrictSlash(true) router := mux.NewRouter().StrictSlash(true)
// 全局中间件 // 全局中间件
router.Use(loggingMiddleware) router.Use(loggingMiddleware)
router.Use(corsMiddleware) 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.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
@ -241,68 +328,100 @@ func setupRouter(
// API v1 // API v1
api := router.PathPrefix("/api/v1").Subrouter() 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/login", authHandler.Login)
api.HandleFunc("/auth/refresh", authHandler.RefreshToken) api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
protected := api.PathPrefix("").Subrouter() // ===== 用户账户路由 =====
protected.Use(authMiddleware(authService)) api.HandleFunc("/users/me", userHandler.GetCurrentUser).Methods(http.MethodGet)
protected.HandleFunc("/auth/me", authHandler.Me).Methods(http.MethodGet) api.HandleFunc("/users/me/password", userHandler.ChangePassword).Methods(http.MethodPut)
protected.HandleFunc("/auth/register", authHandler.Register).Methods(http.MethodPost) api.HandleFunc("/users/me/workspace", userHandler.GetCurrentUserWorkspace).Methods(http.MethodGet)
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)
// ===== 集群路由 ===== // ===== 用户管理路由Admin =====
protected.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost) api.HandleFunc("/admin/users", userManagementHandler.CreateUser).Methods(http.MethodPost)
protected.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet) api.HandleFunc("/admin/users", userManagementHandler.ListUsers).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet) api.HandleFunc("/admin/users/{user_id}", userManagementHandler.GetUser).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut) api.HandleFunc("/admin/users/{user_id}", userManagementHandler.UpdateUser).Methods(http.MethodPut)
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete) api.HandleFunc("/admin/users/{user_id}/active", userManagementHandler.SetUserActive).Methods(http.MethodPut)
protected.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet) api.HandleFunc("/admin/users/{user_id}/workspace", userManagementHandler.ChangeUserWorkspace).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/password", userManagementHandler.ResetPassword).Methods(http.MethodPut)
// ===== Registry 路由 ===== api.HandleFunc("/admin/users/{user_id}", userManagementHandler.DeleteUser).Methods(http.MethodDelete)
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 路由 =====
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 路由 =====
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 路由 =====
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 路由 ===== // ===== Workspace 路由 =====
protected.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet) api.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
protected.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost) api.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
protected.HandleFunc("/workspaces/credentials/kubeconfig", workspaceHandler.IssueCurrentKubeconfig).Methods(http.MethodGet) api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.GetWorkspace).Methods(http.MethodGet)
protected.HandleFunc("/workspaces/{workspace_id}/clusters", workspaceHandler.InitClusterBinding).Methods(http.MethodPost) api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.UpdateWorkspace).Methods(http.MethodPut)
protected.HandleFunc("/workspaces/{workspace_id}/kubeconfig", workspaceHandler.IssueKubeconfig).Methods(http.MethodPost) api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.DeleteWorkspace).Methods(http.MethodDelete)
protected.HandleFunc("/workspaces/{workspace_id}/suspend", workspaceHandler.SuspendWorkspace).Methods(http.MethodPost) api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.GetWorkspaceQuotas).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.SetWorkspaceQuotas).Methods(http.MethodPut)
// ===== 集群路由 =====
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)
// ===== 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)
// ===== 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)
// ===== 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)
// ===== 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)
// 处理 MethodNotAllowed 错误OPTIONS 请求会触发) // 处理 MethodNotAllowed 错误OPTIONS 请求会触发)
router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -317,35 +436,6 @@ func setupRouter(
return router 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 日志中间件 // loggingMiddleware 日志中间件
func loggingMiddleware(next http.Handler) http.Handler { func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -356,25 +446,54 @@ func loggingMiddleware(next http.Handler) http.Handler {
} }
// corsMiddleware CORS 中间件 // corsMiddleware CORS 中间件
func corsMiddleware(next http.Handler) http.Handler { func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(next http.Handler) http.Handler {
// 设置 CORS 头 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin") 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")
// 处理 OPTIONS 预检请求 // 验证 origin 是否在允许列表中
if r.Method == http.MethodOptions { if origin != "" && len(allowedOrigins) > 0 {
w.WriteHeader(http.StatusNoContent) allowed := false
return 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
}
}
next.ServeHTTP(w, r) // 如果没有配置 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)
})
}
} }

View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
fmt.Println(string(hash))
}

View File

@ -2,9 +2,9 @@
"enabled": true, "enabled": true,
"users": [ "users": [
{ {
"username": "bootstrap-admin", "username": "admin",
"password": "replace-with-a-strong-password", "password": "change-me-in-production",
"email": "bootstrap-admin@example.local" "email": "admin@example.com"
} }
], ],
"registries": [ "registries": [
@ -12,8 +12,8 @@
"name": "my-harbor", "name": "my-harbor",
"url": "https://harbor.example.com", "url": "https://harbor.example.com",
"description": "Harbor Registry", "description": "Harbor Registry",
"username": "robot$project+ocdp", "username": "admin",
"password": "replace-with-robot-token", "password": "change-me",
"insecure": false "insecure": false
} }
], ],
@ -28,3 +28,4 @@
} }
] ]
} }

View File

@ -37,7 +37,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
ports: ports:
- "${POSTGRES_PORT:-15432}:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro - ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro
@ -58,29 +58,47 @@ services:
build: build:
context: ${BACKEND_BUILD_CONTEXT:-.} context: ${BACKEND_BUILD_CONTEXT:-.}
dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile} dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile}
args:
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
image: ocdp-backend:latest image: ocdp-backend:latest
container_name: ocdp-backend container_name: ocdp-backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- path: ../.env - /media/ivanwu/DATA/ocdp-go/.env
required: false
format: raw
environment: environment:
ADAPTER_MODE: ${ADAPTER_MODE:-production} ADAPTER_MODE: ${ADAPTER_MODE:-production}
PORT: 8080 PORT: 8080
JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here} 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 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: ports:
- "${BACKEND_PORT:-18081}:8080" - "${BACKEND_PORT:-8080}:8080"
volumes: volumes:
- ./config:/app/config:ro - ./config:/app/config:ro
- ./data:/app/data - ./data:/app/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@ -101,9 +119,6 @@ services:
build: build:
context: ${BACKEND_BUILD_CONTEXT:-.} context: ${BACKEND_BUILD_CONTEXT:-.}
dockerfile: ${BACKEND_MOCK_BUILD_DOCKERFILE:-Dockerfile.mock} 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 container_name: ocdp-backend-mock
restart: unless-stopped restart: unless-stopped
environment: environment:
@ -112,9 +127,9 @@ services:
JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key} JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long}
ports: ports:
- "${BACKEND_PORT:-18081}:8080" - "${BACKEND_PORT:-8080}:8080"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@ -134,7 +149,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local} PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change-me} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
PGADMIN_CONFIG_SERVER_MODE: "False" PGADMIN_CONFIG_SERVER_MODE: "False"
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
ports: ports:

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

3047
backend/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

3027
backend/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1975
backend/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ require (
dario.cat/mergo v1.0.1 // indirect dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // 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/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect
@ -46,6 +47,7 @@ require (
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // 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/go-openapi/swag v0.23.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
@ -93,10 +95,12 @@ require (
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // 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/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.45.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
@ -104,6 +108,7 @@ require (
golang.org/x/term v0.36.0 // indirect golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.12.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/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/grpc v1.72.1 // indirect google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect

View File

@ -10,6 +10,8 @@ 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/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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 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 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@ -20,6 +22,8 @@ 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/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 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= 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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 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= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@ -94,11 +98,18 @@ 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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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.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 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 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 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 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.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 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
@ -160,6 +171,7 @@ 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/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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -175,6 +187,9 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= 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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@ -213,6 +228,7 @@ 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/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 h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= 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 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 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= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
@ -280,6 +296,8 @@ 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
@ -351,6 +369,7 @@ 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-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-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-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 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@ -365,16 +384,21 @@ 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-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-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-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-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-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-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.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 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 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.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.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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@ -399,15 +423,19 @@ 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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 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 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k= helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=

34
backend/hash.go Normal file
View File

@ -0,0 +1,34 @@
//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)
}

View File

@ -6,9 +6,9 @@ type RepositoryListResponse struct {
RegistryURL string `json:"registryUrl"` RegistryURL string `json:"registryUrl"`
Repositories []string `json:"repositories"` Repositories []string `json:"repositories"`
Total int `json:"total"` Total int `json:"total"`
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable" Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
Message string `json:"message,omitempty"` // User-friendly message Message string `json:"message,omitempty"` // User-friendly message
} }
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段) // ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
@ -23,11 +23,11 @@ type ArtifactResponse struct {
// TagResponse Tag 响应(前端期望的扁平化结构) // TagResponse Tag 响应(前端期望的扁平化结构)
type TagResponse struct { type TagResponse struct {
RepositoryName string `json:"repositoryName"` // Repository name RepositoryName string `json:"repositoryName"` // Repository name
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest") Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
Type string `json:"type"` // Artifact type: chart, image, other Type string `json:"type"` // Artifact type: chart, image, other
MediaType string `json:"mediaType,omitempty"` MediaType string `json:"mediaType,omitempty"`
Size int64 `json:"size"` // Artifact size (bytes) Size int64 `json:"size"` // Artifact size (bytes)
} }
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口) // ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
@ -42,7 +42,8 @@ type ValuesSchemaResponse struct {
Schema string `json:"schema"` Schema string `json:"schema"`
} }
// ValuesYAMLResponse Helm Chart 默认 values.yaml 响应 // ValuesResponse Values 响应
type ValuesYAMLResponse struct { type ValuesResponse struct {
ValuesYAML string `json:"valuesYaml"` Values string `json:"values"`
} }

View File

@ -2,18 +2,8 @@ package dto
// RegisterRequest 用户注册请求 // RegisterRequest 用户注册请求
type RegisterRequest struct { type RegisterRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"` 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 用户登录请求 // LoginRequest 用户登录请求
@ -29,53 +19,17 @@ type RefreshTokenRequest struct {
// AuthResponse 认证响应 // AuthResponse 认证响应
type AuthResponse struct { type AuthResponse struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"` RefreshToken string `json:"refreshToken"`
UserID string `json:"userId"` UserID string `json:"userId"`
Username string `json:"username"` 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 用户信息响应 // UserResponse 用户信息响应
type UserResponse struct { type UserResponse struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` CreatedAt string `json:"createdAt"`
WorkspaceID string `json:"workspaceId"` UpdatedAt string `json:"updatedAt"`
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"`
} }

View File

@ -0,0 +1,31 @@
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"`
}

View File

@ -12,10 +12,9 @@ type CreateClusterRequest struct {
KeyDataAlt string `json:"key_data"` KeyDataAlt string `json:"key_data"`
Token string `json:"token"` Token string `json:"token"`
Description string `json:"description"` Description string `json:"description"`
Visibility string `json:"visibility"` IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
GlobalShared bool `json:"globalShared"` DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
GlobalSharedAlt bool `json:"global_shared"` IsShared bool `json:"isShared"` // 是否为共享集群
DefaultNamespace string `json:"defaultNamespace"`
} }
// UpdateClusterRequest 更新集群请求 // UpdateClusterRequest 更新集群请求
@ -30,10 +29,9 @@ type UpdateClusterRequest struct {
KeyDataAlt string `json:"key_data"` KeyDataAlt string `json:"key_data"`
Token string `json:"token"` Token string `json:"token"`
Description string `json:"description"` Description string `json:"description"`
Visibility string `json:"visibility"` IsolationMode string `json:"isolationMode"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
DefaultNamespace string `json:"defaultNamespace"` DefaultNamespace string `json:"defaultNamespace"`
IsShared *bool `json:"isShared"`
} }
// Normalize 将多种命名风格的字段合并到统一字段 // Normalize 将多种命名风格的字段合并到统一字段
@ -64,15 +62,16 @@ func (r *UpdateClusterRequest) Normalize() {
// ClusterResponse 集群响应(敏感数据已脱敏) // ClusterResponse 集群响应(敏感数据已脱敏)
type ClusterResponse struct { type ClusterResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` WorkspaceID string `json:"workspaceId,omitempty"`
Host string `json:"host"` OwnerID string `json:"ownerId,omitempty"`
Description string `json:"description"` Name string `json:"name"`
WorkspaceID string `json:"workspaceId"` Host string `json:"host"`
OwnerID string `json:"ownerId"` Description string `json:"description"`
Visibility string `json:"visibility"` IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
DefaultNamespace string `json:"defaultNamespace,omitempty"` DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
AllowedActions []string `json:"allowedActions,omitempty"` IsShared bool `json:"isShared"` // 是否为共享集群
// 认证配置状态(不返回实际证书数据,仅返回是否已配置) // 认证配置状态(不返回实际证书数据,仅返回是否已配置)
HasCAData bool `json:"hasCaData"` HasCAData bool `json:"hasCaData"`
HasCertData bool `json:"hasCertData"` HasCertData bool `json:"hasCertData"`

View File

@ -1,6 +1,8 @@
package dto package dto
import ( import (
"time"
"github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/pkg/crypto" "github.com/ocdp/cluster-service/internal/pkg/crypto"
) )
@ -8,17 +10,17 @@ import (
// ToRegistryResponse 转换 Registry 实体为响应 DTO脱敏 // ToRegistryResponse 转换 Registry 实体为响应 DTO脱敏
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse { func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
response := &RegistryResponse{ response := &RegistryResponse{
ID: registry.ID, ID: registry.ID,
WorkspaceID: registry.WorkspaceID, WorkspaceID: registry.WorkspaceID,
OwnerID: registry.OwnerID, OwnerID: registry.OwnerID,
Visibility: registry.Visibility, Name: registry.Name,
Name: registry.Name, URL: registry.URL,
URL: registry.URL, Description: registry.Description,
Description: registry.Description, Username: registry.Username,
Username: registry.Username, Insecure: registry.Insecure,
Insecure: registry.Insecure, IsShared: registry.IsShared,
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
} }
// 脱敏处理密码 // 脱敏处理密码
@ -36,11 +38,12 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
ID: cluster.ID, ID: cluster.ID,
WorkspaceID: cluster.WorkspaceID, WorkspaceID: cluster.WorkspaceID,
OwnerID: cluster.OwnerID, OwnerID: cluster.OwnerID,
Visibility: cluster.Visibility,
Name: cluster.Name, Name: cluster.Name,
Host: cluster.Host, Host: cluster.Host,
Description: cluster.Description, Description: cluster.Description,
IsolationMode: string(cluster.IsolationMode),
DefaultNamespace: cluster.DefaultNamespace, DefaultNamespace: cluster.DefaultNamespace,
IsShared: cluster.IsShared,
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
} }
@ -67,3 +70,84 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
return response 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")
}

View File

@ -12,3 +12,4 @@ type SuccessResponse struct {
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }

View File

@ -2,25 +2,24 @@ package dto
// CreateInstanceRequest 创建实例请求 // CreateInstanceRequest 创建实例请求
type CreateInstanceRequest struct { type CreateInstanceRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Namespace string `json:"namespace" binding:"required"` Namespace string `json:"namespace" binding:"required"`
RegistryID string `json:"registryId" binding:"required"` RegistryID string `json:"registryId" binding:"required"`
RegistryIDAlt string `json:"registry_id"` RegistryIDAlt string `json:"registry_id"`
Repository string `json:"repository" binding:"required"` Repository string `json:"repository" binding:"required"`
Tag string `json:"tag" binding:"required"` Tag string `json:"tag"`
Description string `json:"description"` Version string `json:"version"`
Values map[string]interface{} `json:"values"` Description string `json:"description"`
ValuesYAML string `json:"valuesYaml"` Values map[string]interface{} `json:"values"`
ValuesYAMLAlt string `json:"values_yaml"` ValuesYAML string `json:"valuesYaml"`
} }
// UpdateInstanceRequest 更新实例请求 // UpdateInstanceRequest 更新实例请求
type UpdateInstanceRequest struct { type UpdateInstanceRequest struct {
Version string `json:"version"` Version string `json:"version"`
Description string `json:"description"` Description string `json:"description"`
Values map[string]interface{} `json:"values"` Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"` ValuesYAML string `json:"valuesYaml"`
ValuesYAMLAlt string `json:"values_yaml"`
} }
// Normalize 将多种命名风格的字段合并到统一字段 // Normalize 将多种命名风格的字段合并到统一字段
@ -28,15 +27,9 @@ func (r *CreateInstanceRequest) Normalize() {
if r.RegistryID == "" { if r.RegistryID == "" {
r.RegistryID = r.RegistryIDAlt r.RegistryID = r.RegistryIDAlt
} }
if r.ValuesYAML == "" { // Support both "tag" and "version" field names from frontend
r.ValuesYAML = r.ValuesYAMLAlt if r.Tag == "" {
} r.Tag = r.Version
}
// Normalize 将多种命名风格的字段合并到统一字段
func (r *UpdateInstanceRequest) Normalize() {
if r.ValuesYAML == "" {
r.ValuesYAML = r.ValuesYAMLAlt
} }
} }
@ -55,27 +48,23 @@ type DeleteInstanceRequest struct {
// InstanceResponse 实例响应 // InstanceResponse 实例响应
type InstanceResponse struct { type InstanceResponse struct {
ID string `json:"id"` ID string `json:"id"`
ClusterID string `json:"clusterId"` ClusterID string `json:"clusterId"`
Name string `json:"name"` Name string `json:"name"`
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
RegistryID string `json:"registryId"` RegistryID string `json:"registryId"`
Repository string `json:"repository"` Repository string `json:"repository"`
Chart string `json:"chart"` Chart string `json:"chart"`
Version string `json:"version"` Version string `json:"version"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
WorkspaceID string `json:"workspaceId"` StatusReason string `json:"statusReason,omitempty"`
OwnerID string `json:"ownerId"` LastOperation string `json:"lastOperation,omitempty"`
AllowedActions []string `json:"allowedActions,omitempty"` LastError string `json:"lastError,omitempty"`
StatusReason string `json:"statusReason,omitempty"` Revision int `json:"revision"`
LastOperation string `json:"lastOperation,omitempty"` Values map[string]interface{} `json:"values,omitempty"`
LastError string `json:"lastError,omitempty"` CreatedAt string `json:"createdAt"`
Revision int `json:"revision"` UpdatedAt string `json:"updatedAt"`
Values map[string]interface{} `json:"values,omitempty"`
Replicas int `json:"replicas"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
} }
// InstanceStatusResponse 实例状态响应 // InstanceStatusResponse 实例状态响应
@ -147,89 +136,3 @@ type InstanceEntryResponse struct {
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"` Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
TLS []InstanceEntryTLSResponse `json:"tls,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"`
}

View File

@ -8,29 +8,29 @@ import (
// ClusterMetricsResponse 集群监控响应 // ClusterMetricsResponse 集群监控响应
type ClusterMetricsResponse struct { type ClusterMetricsResponse struct {
ClusterID string `json:"clusterId"` ClusterID string `json:"clusterId"`
ClusterName string `json:"clusterName"` ClusterName string `json:"clusterName"`
Status string `json:"status"` Status string `json:"status"`
Uptime string `json:"uptime"` Uptime string `json:"uptime"`
NodeCount int `json:"nodeCount"` NodeCount int `json:"nodeCount"`
PodCount int `json:"podCount"` PodCount int `json:"podCount"`
LastCheck time.Time `json:"lastCheck"` LastCheck time.Time `json:"lastCheck"`
TotalCPU string `json:"totalCpu"` TotalCPU string `json:"totalCpu"`
TotalMemory string `json:"totalMemory"` TotalMemory string `json:"totalMemory"`
TotalGPU int `json:"totalGpu"` TotalGPU int `json:"totalGpu"`
UsedCPU string `json:"usedCpu"` UsedCPU string `json:"usedCpu"`
UsedMemory string `json:"usedMemory"` UsedMemory string `json:"usedMemory"`
UsedGPU int `json:"usedGpu"` UsedGPU int `json:"usedGpu"`
CPUUsage float64 `json:"cpuUsage"` CPUUsage float64 `json:"cpuUsage"`
MemoryUsage float64 `json:"memoryUsage"` MemoryUsage float64 `json:"memoryUsage"`
GPUUsage float64 `json:"gpuUsage"` GPUUsage float64 `json:"gpuUsage"`
MaxNodeCPU string `json:"maxNodeCpu"` MaxNodeCPU string `json:"maxNodeCpu"`
MaxNodeMemory string `json:"maxNodeMemory"` MaxNodeMemory string `json:"maxNodeMemory"`
MaxNodeGPU int `json:"maxNodeGpu"` MaxNodeGPU int `json:"maxNodeGpu"`
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"` MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"` MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"` MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
Nodes []NodeMetricsResponse `json:"nodes,omitempty"` Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
} }
// NodeMetricsResponse 节点监控响应 // NodeMetricsResponse 节点监控响应
@ -72,28 +72,28 @@ type MonitoringSummaryResponse struct {
// ToClusterMetricsResponse 转换为响应 // ToClusterMetricsResponse 转换为响应
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse { func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
resp := &ClusterMetricsResponse{ resp := &ClusterMetricsResponse{
ClusterID: m.ClusterID, ClusterID: m.ClusterID,
ClusterName: m.ClusterName, ClusterName: m.ClusterName,
Status: m.Status, Status: m.Status,
Uptime: m.Uptime, Uptime: m.Uptime,
NodeCount: m.NodeCount, NodeCount: m.NodeCount,
PodCount: m.PodCount, PodCount: m.PodCount,
LastCheck: m.LastCheck, LastCheck: m.LastCheck,
TotalCPU: m.TotalCPU, TotalCPU: m.TotalCPU,
TotalMemory: m.TotalMemory, TotalMemory: m.TotalMemory,
TotalGPU: m.TotalGPU, TotalGPU: m.TotalGPU,
UsedCPU: m.UsedCPU, UsedCPU: m.UsedCPU,
UsedMemory: m.UsedMemory, UsedMemory: m.UsedMemory,
UsedGPU: m.UsedGPU, UsedGPU: m.UsedGPU,
CPUUsage: m.CPUUsage, CPUUsage: m.CPUUsage,
MemoryUsage: m.MemoryUsage, MemoryUsage: m.MemoryUsage,
GPUUsage: m.GPUUsage, GPUUsage: m.GPUUsage,
MaxNodeCPU: m.MaxNodeCPU, MaxNodeCPU: m.MaxNodeCPU,
MaxNodeMemory: m.MaxNodeMemory, MaxNodeMemory: m.MaxNodeMemory,
MaxNodeGPU: m.MaxNodeGPU, MaxNodeGPU: m.MaxNodeGPU,
MaxNodeCPUUsage: m.MaxNodeCPUUsage, MaxNodeCPUUsage: m.MaxNodeCPUUsage,
MaxNodeMemUsage: m.MaxNodeMemUsage, MaxNodeMemUsage: m.MaxNodeMemUsage,
MaxNodeGPUUsage: m.MaxNodeGPUUsage, MaxNodeGPUUsage: m.MaxNodeGPUUsage,
} }
if len(m.Nodes) > 0 { if len(m.Nodes) > 0 {
@ -140,3 +140,4 @@ func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummary
LastUpdate: s.LastUpdate, LastUpdate: s.LastUpdate,
} }
} }

View File

@ -2,46 +2,39 @@ package dto
// CreateRegistryRequest 创建 Registry 请求 // CreateRegistryRequest 创建 Registry 请求
type CreateRegistryRequest struct { type CreateRegistryRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"` URL string `json:"url" binding:"required"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Description string `json:"description"` Description string `json:"description"`
Insecure bool `json:"insecure"` Insecure bool `json:"insecure"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
} }
// UpdateRegistryRequest 更新 Registry 请求 // UpdateRegistryRequest 更新 Registry 请求
type UpdateRegistryRequest struct { type UpdateRegistryRequest struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Description string `json:"description"` Description string `json:"description"`
Insecure bool `json:"insecure"` Insecure bool `json:"insecure"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
} }
// RegistryResponse Registry 响应(敏感数据已脱敏) // RegistryResponse Registry 响应(敏感数据已脱敏)
type RegistryResponse struct { type RegistryResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` WorkspaceID string `json:"workspace_id,omitempty"`
URL string `json:"url"` OwnerID string `json:"owner_id,omitempty"`
Description string `json:"description"` Name string `json:"name"`
WorkspaceID string `json:"workspaceId"` URL string `json:"url"`
OwnerID string `json:"ownerId"` Description string `json:"description"`
Visibility string `json:"visibility"` Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
AllowedActions []string `json:"allowedActions,omitempty"` Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感) HasPassword bool `json:"hasPassword"` // 是否已设置密码
Password string `json:"password,omitempty"` // 脱敏显示(••••••••) IsShared bool `json:"is_shared"`
HasPassword bool `json:"hasPassword"` // 是否已设置密码 Insecure bool `json:"insecure"`
Insecure bool `json:"insecure"` CreatedAt string `json:"createdAt"`
CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"`
UpdatedAt string `json:"updatedAt"`
} }
// RegistryHealthResponse Registry 健康状态响应 // RegistryHealthResponse Registry 健康状态响应
@ -49,3 +42,4 @@ type RegistryHealthResponse struct {
Healthy bool `json:"healthy"` Healthy bool `json:"healthy"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }

View File

@ -0,0 +1,76 @@
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"`
}

View File

@ -0,0 +1,78 @@
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"`
}

View File

@ -0,0 +1,37 @@
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"`
}

View File

@ -0,0 +1,75 @@
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"`
}

View File

@ -0,0 +1,284 @@
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))
})
}
}

View File

@ -29,19 +29,14 @@ func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandl
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param registry_id path string true "Registry ID" // @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 // @Success 200 {object} dto.RepositoryListResponse
// @Failure 500 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories [get] // @Router /registries/{registry_id}/repositories [get]
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) { func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
registryID := vars["registry_id"] registryID := vars["registry_id"]
artifactType := r.URL.Query().Get("artifact_type")
if artifactType == "" {
artifactType = "chart"
}
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID, artifactType) repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error()) respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
return return
@ -55,17 +50,13 @@ func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Reques
} }
// Determine source and message based on repository count // Determine source and message based on repository count
source := "harbor-api" source := "catalog"
catalogSupported := true catalogSupported := true
message := "" message := ""
if len(repositories) == 0 { if len(repositories) == 0 {
source = "unavailable" source = "unavailable"
if artifactType == "chart" { message = "No repositories found in this registry"
message = "No chart repositories found in this registry"
} else {
message = "No repositories found in this registry"
}
} }
response := &dto.RepositoryListResponse{ response := &dto.RepositoryListResponse{
@ -201,36 +192,41 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
respondJSON(w, http.StatusOK, response) respondJSON(w, http.StatusOK, response)
} }
// GetArtifactValuesYAML 获取 Helm Chart 的默认 values.yaml // GetArtifactValues 获取 Helm Chart 的 values.yaml
// @Summary 获取 Helm Chart 默认 Values YAML // @Summary 获取 Helm Chart Values
// @Description 获取 Helm Chart 包内原始 values.yaml,用于高级覆盖编辑 // @Description 获取 Helm Chart values.yaml 文件内容 (仅支持 Chart 类型)
// @Tags Artifacts // @Tags Artifacts
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param registry_id path string true "Registry ID" // @Param registry_id path string true "Registry ID"
// @Param repository_name path string true "Repository Name (URL encoded)" // @Param repository_name path string true "Repository Name (URL encoded)"
// @Param reference path string true "Artifact Reference (tag or digest)" // @Param reference path string true "Artifact Reference (tag or digest)"
// @Success 200 {object} dto.ValuesYAMLResponse // @Success 200 {object} dto.ValuesResponse
// @Failure 500 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-yaml [get] // @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values [get]
func (h *ArtifactHandler) GetArtifactValuesYAML(w http.ResponseWriter, r *http.Request) { func (h *ArtifactHandler) GetArtifactValues(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
registryID := vars["registry_id"] registryID := vars["registry_id"]
repositoryName := vars["repository_name"] repositoryName := vars["repository_name"]
reference := vars["reference"] reference := vars["reference"]
valuesYAML, err := h.artifactService.GetValuesYAML(r.Context(), registryID, repositoryName, reference) values, err := h.artifactService.GetValues(r.Context(), registryID, repositoryName, reference)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, entity.ErrRegistryNotFound), case errors.Is(err, entity.ErrRegistryNotFound),
errors.Is(err, entity.ErrRepositoryNotFound), errors.Is(err, entity.ErrRepositoryNotFound),
errors.Is(err, entity.ErrArtifactNotFound): errors.Is(err, entity.ErrArtifactNotFound),
respondError(w, http.StatusNotFound, "Values YAML not found", err.Error()) errors.Is(err, entity.ErrValuesNotFound):
respondError(w, http.StatusNotFound, "Values not found", err.Error())
default: default:
respondError(w, http.StatusInternalServerError, "Failed to get values YAML", err.Error()) respondError(w, http.StatusInternalServerError, "Failed to get values", err.Error())
} }
return return
} }
respondJSON(w, http.StatusOK, &dto.ValuesYAMLResponse{ValuesYAML: valuesYAML}) response := &dto.ValuesResponse{
Values: values,
}
respondJSON(w, http.StatusOK, response)
} }

View File

@ -1,16 +1,11 @@
package rest package rest
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto" "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/domain/service"
"github.com/ocdp/cluster-service/internal/pkg/authz"
) )
// AuthHandler 认证 Handler // AuthHandler 认证 Handler
@ -25,9 +20,9 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler {
} }
} }
// Register 管理员创建用户 // Register 用户注册
// @Summary 管理员创建用户 // @Summary 用户注册
// @Description 创建一个新的后台用户。公开自注册已禁用,只允许 admin 调用。 // @Description 创建一个新的后台用户
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -43,64 +38,22 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
} }
// 调用领域服务 // 调用领域服务
user, err := h.authService.Register(r.Context(), req.Username, req.Password, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{ user, err := h.authService.Register(r.Context(), req.Username, req.Password)
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 { if err != nil {
respondServiceError(w, err, "Registration failed") respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
return return
} }
respondJSON(w, http.StatusCreated, h.convertUserResponse(r.Context(), user)) // 返回响应
} 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"),
}
func (h *AuthHandler) ListUsers(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, response)
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 用户登录 // Login 用户登录
@ -121,56 +74,23 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
} }
// 调用领域服务 // 调用领域服务
accessToken, refreshToken, user, err := h.authService.Login(r.Context(), req.Username, req.Password) accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
if err != nil { if err != nil {
respondError(w, http.StatusUnauthorized, "Login failed", err.Error()) respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
return return
} }
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID) // 获取用户信息
// TODO: 从 token 解析用户信息或从服务获取
// 返回响应 // 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致
response := &dto.AuthResponse{ response := &dto.AuthResponse{
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
UserID: user.ID, Username: req.Username,
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,
} }
respondJSON(w, http.StatusOK, response) respondSuccess(w, "Login successful", 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 // RefreshToken 刷新 Token
@ -191,109 +111,17 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
} }
// 调用领域服务 // 调用领域服务
newAccessToken, user, err := h.authService.RefreshToken(r.Context(), req.RefreshToken) newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
if err != nil { if err != nil {
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error()) respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
return return
} }
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
// 返回响应 // 返回响应 - 使用 respondSuccess 包装
response := &dto.AuthResponse{ response := &dto.AuthResponse{
AccessToken: newAccessToken, AccessToken: newAccessToken,
RefreshToken: req.RefreshToken, 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,
} }
respondJSON(w, http.StatusOK, response) respondSuccess(w, "Token refreshed", 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
} }

View File

@ -0,0 +1,229 @@
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"),
}
}

View File

@ -40,18 +40,20 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return return
} }
req.Normalize() req.Normalize()
// 创建实体 // 创建实体
cluster := entity.NewCluster(req.Name, req.Host) cluster := entity.NewCluster("", "", req.Name, req.Host)
cluster.Description = req.Description cluster.Description = req.Description
cluster.Visibility = req.Visibility
if req.GlobalShared || req.GlobalSharedAlt {
cluster.Visibility = "global_shared"
}
cluster.DefaultNamespace = req.DefaultNamespace
if req.CertData != "" && req.KeyData != "" { // 设置认证信息
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 {
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData) cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
} else if req.Token != "" { } else if req.Token != "" {
cluster.SetTokenAuth(req.Token) cluster.SetTokenAuth(req.Token)
@ -62,6 +64,18 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=", "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=",
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t", "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
}
} }
// 调用领域服务 // 调用领域服务
@ -152,15 +166,6 @@ func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
// 更新字段 // 更新字段
cluster.Update(req.Name, req.Host, req.Description) 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 != "" { if req.CertData != "" && req.KeyData != "" {
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData) cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
@ -212,18 +217,24 @@ func (h *ClusterHandler) GetClusterHealth(w http.ResponseWriter, r *http.Request
vars := mux.Vars(r) vars := mux.Vars(r)
clusterID := vars["cluster_id"] clusterID := vars["cluster_id"]
// 检查集群是否存在 // 获取集群
_, err := h.clusterService.GetCluster(r.Context(), clusterID) cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "Cluster not found", err.Error()) respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
return return
} }
// TODO: 实现真实的健康检查 // 测试连接
err = h.clusterService.TestConnection(r.Context(), cluster)
response := &dto.ClusterHealthResponse{ response := &dto.ClusterHealthResponse{
Healthy: true, Healthy: err == nil,
Message: "Cluster is healthy", }
Version: "v1.28.0",
if err != nil {
response.Message = err.Error()
} else {
response.Message = "Cluster is healthy"
} }
respondJSON(w, http.StatusOK, response) respondJSON(w, http.StatusOK, response)

View File

@ -2,17 +2,13 @@ package rest
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto" "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/entity"
"github.com/ocdp/cluster-service/internal/domain/service" "github.com/ocdp/cluster-service/internal/domain/service"
"gopkg.in/yaml.v3"
) )
// InstanceHandler 实例 Handler // InstanceHandler 实例 Handler
@ -49,6 +45,10 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
return return
} }
req.Normalize() 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") // Extract chart name from repository (e.g., "charts/nginx" -> "nginx")
chart := req.Repository chart := req.Repository
@ -58,10 +58,14 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
// 创建实体 // 创建实体
instance := entity.NewInstance( instance := entity.NewInstance(
"", // workspaceID - will be set based on user
"", // ownerID - will be set based on user
clusterID, clusterID,
req.RegistryID,
"", // chartReferenceID - not used in legacy API
"", // valuesTemplateID - not used in legacy API
req.Name, req.Name,
req.Namespace, req.Namespace,
req.RegistryID,
req.Repository, req.Repository,
chart, // Extracted chart name chart, // Extracted chart name
req.Tag, // Tag mapped to version req.Tag, // Tag mapped to version
@ -73,14 +77,6 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
} }
if req.ValuesYAML != "" { if req.ValuesYAML != "" {
instance.SetValuesYAML(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)
}
} }
// 调用领域服务 // 调用领域服务
@ -89,7 +85,28 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
return return
} }
respondJSON(w, http.StatusCreated, convertInstanceResponse(instance, true)) // 返回响应
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)
} }
// GetInstance 获取实例详情 // GetInstance 获取实例详情
@ -104,7 +121,6 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
// @Router /clusters/{cluster_id}/instances/{instance_id} [get] // @Router /clusters/{cluster_id}/instances/{instance_id} [get]
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) { func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
clusterID := vars["cluster_id"]
instanceID := vars["instance_id"] instanceID := vars["instance_id"]
instance, err := h.instanceService.GetInstance(r.Context(), instanceID) instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
@ -112,12 +128,28 @@ func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "Instance not found", err.Error()) respondError(w, http.StatusNotFound, "Instance not found", err.Error())
return return
} }
if instance.ClusterID != clusterID {
respondError(w, http.StatusNotFound, "Instance not found", "resource does not belong to cluster") response := &dto.InstanceResponse{
return 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, convertInstanceResponse(instance, true)) respondJSON(w, http.StatusOK, response)
} }
// ListInstances 列出集群的所有实例 // ListInstances 列出集群的所有实例
@ -135,16 +167,31 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID) instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
if err != nil { if err != nil {
respondServiceError(w, err, "Failed to list instances") respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
return return
} }
// Enrich with running replicas from K8s
instances = h.instanceService.EnrichReplicas(r.Context(), clusterID, instances)
responses := make([]*dto.InstanceResponse, 0, len(instances)) responses := make([]*dto.InstanceResponse, 0, len(instances))
for _, instance := range instances { for _, instance := range instances {
responses = append(responses, convertInstanceResponse(instance, false)) 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"),
})
} }
response := &dto.InstanceListResponse{ response := &dto.InstanceListResponse{
@ -176,7 +223,6 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return return
} }
req.Normalize()
// 获取现有实例 // 获取现有实例
instance, err := h.instanceService.GetInstance(r.Context(), instanceID) instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
@ -188,22 +234,12 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
// 更新字段 // 更新字段
if req.Version != "" { if req.Version != "" {
instance.Upgrade(req.Version, req.Values) instance.Upgrade(req.Version, req.Values)
} else if req.Values != nil {
instance.SetValues(req.Values)
} }
if req.Description != "" { if req.Description != "" {
instance.Description = req.Description instance.Description = req.Description
} }
if req.ValuesYAML != "" { if req.ValuesYAML != "" {
instance.SetValuesYAML(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)
}
} }
// 调用领域服务 // 调用领域服务
@ -212,7 +248,27 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
return return
} }
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true)) 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)
} }
// DeleteInstance 删除实例 // DeleteInstance 删除实例
@ -273,153 +329,6 @@ func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Req
respondJSON(w, http.StatusOK, responses) 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 { func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports)) portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
for _, port := range entry.Ports { for _, port := range entry.Ports {
@ -469,196 +378,3 @@ func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryRespons
TLS: tlsResponses, 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
}
}

View File

@ -41,13 +41,9 @@ 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.Description = req.Description
registry.Insecure = req.Insecure registry.Insecure = req.Insecure
registry.Visibility = req.Visibility
if req.GlobalShared || req.GlobalSharedAlt {
registry.Visibility = "global_shared"
}
registry.SetCredentials(req.Username, req.Password) registry.SetCredentials(req.Username, req.Password)
// 调用领域服务 // 调用领域服务
@ -140,12 +136,6 @@ func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request)
// 更新字段 // 更新字段
registry.Update(req.Name, req.URL, req.Description) registry.Update(req.Name, req.URL, req.Description)
registry.Insecure = req.Insecure 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 != "" { if req.Username != "" || req.Password != "" {
registry.SetCredentials(req.Username, req.Password) registry.SetCredentials(req.Username, req.Password)
} }

View File

@ -0,0 +1,340 @@
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"),
}
}

View File

@ -0,0 +1,152 @@
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 ""
}

View File

@ -0,0 +1,332 @@
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
}

View File

@ -32,3 +32,4 @@ func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
} }
respondJSON(w, http.StatusOK, response) respondJSON(w, http.StatusOK, response)
} }

View File

@ -0,0 +1,294 @@
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"),
}
}

View File

@ -3,163 +3,331 @@ package rest
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"time"
"github.com/gorilla/mux" "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/entity"
"github.com/ocdp/cluster-service/internal/domain/service" "github.com/ocdp/cluster-service/internal/domain/service"
"github.com/ocdp/cluster-service/internal/pkg/authz"
) )
// WorkspaceHandler 工作空间 HTTP 处理程序
type WorkspaceHandler struct { type WorkspaceHandler struct {
workspaceService *service.WorkspaceService workspaceService *service.WorkspaceService
authService *service.AuthService
} }
func NewWorkspaceHandler(workspaceService *service.WorkspaceService) *WorkspaceHandler { // NewWorkspaceHandler 创建工作空间处理程序
return &WorkspaceHandler{workspaceService: workspaceService} func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService *service.AuthService) *WorkspaceHandler {
} return &WorkspaceHandler{
workspaceService: workspaceService,
type createWorkspaceRequest struct { authService: authService,
Name string `json:"name"`
}
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"`
}
type bindClusterRequest struct {
ClusterID string `json:"clusterId"`
}
type kubeconfigRequest struct {
ClusterID string `json:"clusterId"`
TTLSeconds int64 `json:"ttlSeconds"`
}
func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
workspaces, err := h.workspaceService.ListWorkspaces(r.Context())
if err != nil {
respondServiceError(w, err, "Failed to list workspaces")
return
} }
response := make([]workspaceResponse, 0, len(workspaces))
for _, workspace := range workspaces {
response = append(response, toWorkspaceResponse(workspace))
}
respondJSON(w, http.StatusOK, response)
} }
// 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) { func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
var req createWorkspaceRequest // 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
var req dto.CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) respondError(w, http.StatusBadRequest, "Invalid request body", "")
return return
} }
workspace, err := h.workspaceService.CreateWorkspace(r.Context(), req.Name)
// 获取创建者 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 { if err != nil {
respondServiceError(w, err, "Failed to create workspace") respondError(w, http.StatusBadRequest, err.Error(), "")
return return
} }
respondJSON(w, http.StatusCreated, toWorkspaceResponse(workspace))
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
} }
func (h *WorkspaceHandler) InitClusterBinding(w http.ResponseWriter, r *http.Request) { // GetWorkspace 获取工作空间
workspaceID := mux.Vars(r)["workspace_id"] // @Summary 获取工作空间
var req bindClusterRequest // @Description 获取指定工作空间的详细信息和配额
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // @Tags workspace
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) // @Accept json
return // @Produce json
} // @Param workspace_id path string true "工作空间 ID"
binding, err := h.workspaceService.EnsureClusterBinding(r.Context(), workspaceID, req.ClusterID) // @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 { if err != nil {
respondServiceError(w, err, "Failed to initialize workspace cluster binding") respondError(w, http.StatusNotFound, "Workspace not found", "")
return return
} }
respondJSON(w, http.StatusOK, binding)
// 检查访问权限
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)
} }
func (h *WorkspaceHandler) IssueKubeconfig(w http.ResponseWriter, r *http.Request) { // UpdateWorkspace 更新工作空间
workspaceID := mux.Vars(r)["workspace_id"] // @Summary 更新工作空间
var req kubeconfigRequest // @Description 更新工作空间信息Admin 专用)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // @Tags workspace
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) // @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 return
} }
kubeconfig, err := h.workspaceService.IssueKubeconfig(r.Context(), workspaceID, req.ClusterID, time.Duration(req.TTLSeconds)*time.Second)
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
if err != nil { if err != nil {
respondServiceError(w, err, "Failed to issue kubeconfig") respondError(w, http.StatusNotFound, "Workspace not found", "")
return return
} }
respondJSON(w, http.StatusOK, map[string]interface{}{
"kubeconfig": kubeconfig.Kubeconfig, var req dto.UpdateWorkspaceRequest
"expiresAt": kubeconfig.ExpiresAt.Format(time.RFC3339), 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))
}
// 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)
}
// 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())
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceListResponse{
Workspaces: dto.WorkspaceDTOsFromEntities(workspaces),
Total: len(workspaces),
}) })
} }
func (h *WorkspaceHandler) IssueCurrentKubeconfig(w http.ResponseWriter, r *http.Request) { // GetWorkspaceQuotas 获取工作空间配额
clusterID := r.URL.Query().Get("clusterId") // @Summary 获取工作空间配额
if clusterID == "" { // @Description 获取指定工作空间的资源配额
clusterID = r.URL.Query().Get("cluster_id") // @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
} }
kubeconfig, err := h.workspaceService.IssueCurrentKubeconfig(r.Context(), clusterID, 2*time.Hour)
quotas, err := h.workspaceService.GetQuotas(r.Context(), workspaceID)
if err != nil { if err != nil {
respondServiceError(w, err, "Failed to issue kubeconfig") respondError(w, http.StatusInternalServerError, err.Error(), "")
return return
} }
w.Header().Set("Content-Type", "application/x-yaml")
w.Header().Set("X-OCDP-Kubeconfig-Expires-At", kubeconfig.ExpiresAt.Format(time.RFC3339)) respondSuccess(w, "", dto.QuotaDTOsFromEntities(quotas))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(kubeconfig.Kubeconfig))
} }
func (h *WorkspaceHandler) SuspendWorkspace(w http.ResponseWriter, r *http.Request) { // SetWorkspaceQuotas 设置工作空间配额
workspaceID := mux.Vars(r)["workspace_id"] // @Summary 设置工作空间配额
if err := h.workspaceService.SuspendWorkspace(r.Context(), workspaceID); err != nil { // @Description 设置指定工作空间的 CPU/GPU/GPU Memory 配额Admin 专用)
respondServiceError(w, err, "Failed to suspend workspace") // @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 return
} }
w.WriteHeader(http.StatusNoContent)
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
var req dto.SetQuotasRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
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}
}
if err := h.workspaceService.SetQuotas(r.Context(), workspaceID, quotas); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
// 返回更新后的配额
updatedQuotas, _ := h.workspaceService.GetQuotas(r.Context(), workspaceID)
respondSuccess(w, "", dto.QuotaDTOsFromEntities(updatedQuotas))
} }
func toWorkspaceResponse(workspace *entity.Workspace) workspaceResponse { // requireAdmin 检查是否为 Admin
return workspaceResponse{ func (h *WorkspaceHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
ID: workspace.ID, userRole := r.Header.Get("X-User-Role")
Name: workspace.Name, if userRole != string(entity.RoleAdmin) {
Status: string(workspace.Status), respondError(w, http.StatusForbidden, "Admin access required", "")
K8sNamespace: workspace.K8sNamespace, return false
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),
} }
return true
} }
func respondServiceError(w http.ResponseWriter, err error, fallback string) { // canAccessWorkspace 检查是否可以访问工作空间
switch err { func (h *WorkspaceHandler) canAccessWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
case entity.ErrUnauthorized, authz.ErrUnauthenticated: userRole := r.Header.Get("X-User-Role")
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error()) userWorkspaceID := r.Header.Get("X-Workspace-ID")
case entity.ErrForbidden, authz.ErrForbidden, entity.ErrUserInactive, entity.ErrWorkspaceSuspended:
respondError(w, http.StatusForbidden, "Forbidden", err.Error()) // Admin 可以访问所有
case entity.ErrClusterNotFound, entity.ErrRegistryNotFound, entity.ErrInstanceNotFound, entity.ErrWorkspaceNotFound: if userRole == string(entity.RoleAdmin) {
respondError(w, http.StatusNotFound, fallback, err.Error()) return true
default:
respondError(w, http.StatusBadRequest, fallback, err.Error())
} }
// 普通用户只能访问自己的 workspace
if userWorkspaceID != workspaceID {
respondError(w, http.StatusForbidden, "Access denied", "")
return false
}
return true
} }

View File

@ -96,36 +96,6 @@ func (f *AdapterFactory) CreateInstanceRepository() (repository.InstanceReposito
return postgres.NewInstanceRepository(f.db), nil 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 客户端 // CreateOCIClient 创建 OCI 客户端
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) { func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
if f.mode == ModeMock { if f.mode == ModeMock {
@ -157,18 +127,67 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
return k8s.NewEntryClient() return k8s.NewEntryClient()
} }
func (f *AdapterFactory) CreateDiagnosticsClient() repository.InstanceDiagnosticsClient { // CreateWorkspaceRepository 创建 Workspace 仓储
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
if f.mode == ModeMock { if f.mode == ModeMock {
return k8s.NewMockDiagnosticsClient() return nil, fmt.Errorf("workspace repository mock not implemented")
} }
return k8s.NewDiagnosticsClient()
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewWorkspaceRepository(f.db), nil
} }
func (f *AdapterFactory) CreateTenantKubeClient() repository.TenantKubeClient { // CreateQuotaRepository 创建 Quota 仓储
// CreateStorageRepository 创建存储后端仓储
func (f *AdapterFactory) CreateStorageRepository() (repository.StorageRepository, error) {
if f.mode == ModeMock { if f.mode == ModeMock {
return k8s.NewMockTenantClient() return mock.NewStorageRepositoryMock(), nil
} }
return k8s.NewTenantClient()
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
} }
// CreateAllRepositories 一次性创建所有 Repositories // CreateAllRepositories 一次性创建所有 Repositories
@ -198,14 +217,14 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
return nil, fmt.Errorf("failed to create workspace repository: %w", err) return nil, fmt.Errorf("failed to create workspace repository: %w", err)
} }
bindingRepo, err := f.CreateWorkspaceClusterBindingRepository() storageRepo, err := f.CreateStorageRepository()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create workspace cluster binding repository: %w", err) return nil, fmt.Errorf("failed to create storage repository: %w", err)
} }
auditRepo, err := f.CreateAuditLogRepository() quotaRepo, err := f.CreateQuotaRepository()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create audit log repository: %w", err) return nil, fmt.Errorf("failed to create quota repository: %w", err)
} }
ociClient, err := f.CreateOCIClient() ociClient, err := f.CreateOCIClient()
@ -221,41 +240,49 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
// 创建 Metrics client依赖 clusterRepo // 创建 Metrics client依赖 clusterRepo
metricsClient := f.CreateMetricsClient(clusterRepo) metricsClient := f.CreateMetricsClient(clusterRepo)
entryClient := f.CreateEntryClient() entryClient := f.CreateEntryClient()
diagnosticsClient := f.CreateDiagnosticsClient()
tenantClient := f.CreateTenantKubeClient() 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)
}
return &Repositories{ return &Repositories{
UserRepo: userRepo, UserRepo: userRepo,
WorkspaceRepo: workspaceRepo, ClusterRepo: clusterRepo,
BindingRepo: bindingRepo, RegistryRepo: registryRepo,
AuditRepo: auditRepo, InstanceRepo: instanceRepo,
ClusterRepo: clusterRepo, WorkspaceRepo: workspaceRepo,
RegistryRepo: registryRepo, StorageRepo: storageRepo,
InstanceRepo: instanceRepo, ChartRefRepo: chartRefRepo,
OCIClient: ociClient, ValuesTemplateRepo: valuesTemplateRepo,
HelmClient: helmClient, QuotaRepo: quotaRepo,
MetricsClient: metricsClient, OCIClient: ociClient,
EntryClient: entryClient, HelmClient: helmClient,
DiagnosticsClient: diagnosticsClient, MetricsClient: metricsClient,
TenantKubeClient: tenantClient, EntryClient: entryClient,
}, nil }, nil
} }
// Repositories 所有仓储的集合 // Repositories 所有仓储的集合
type Repositories struct { type Repositories struct {
UserRepo repository.UserRepository UserRepo repository.UserRepository
WorkspaceRepo repository.WorkspaceRepository ClusterRepo repository.ClusterRepository
BindingRepo repository.WorkspaceClusterBindingRepository RegistryRepo repository.RegistryRepository
AuditRepo repository.AuditLogRepository InstanceRepo repository.InstanceRepository
ClusterRepo repository.ClusterRepository WorkspaceRepo repository.WorkspaceRepository
RegistryRepo repository.RegistryRepository StorageRepo repository.StorageRepository
InstanceRepo repository.InstanceRepository ChartRefRepo repository.ChartReferenceRepository
OCIClient repository.OCIClient ValuesTemplateRepo repository.ValuesTemplateRepository
HelmClient repository.HelmClient QuotaRepo repository.QuotaRepository
MetricsClient repository.MetricsClient OCIClient repository.OCIClient
EntryClient repository.InstanceEntryClient HelmClient repository.HelmClient
DiagnosticsClient repository.InstanceDiagnosticsClient MetricsClient repository.MetricsClient
TenantKubeClient repository.TenantKubeClient EntryClient repository.InstanceEntryClient
} }
// ensureDBConnection 确保数据库连接已建立 // ensureDBConnection 确保数据库连接已建立

View File

@ -194,13 +194,3 @@ func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster,
return instance.Values, nil 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
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -21,7 +22,6 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/restmapper" "k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
) )
// HelmClient 真实的 Helm 客户端实现 // HelmClient 真实的 Helm 客户端实现
@ -37,45 +37,39 @@ func NewHelmClient() repository.HelmClient {
} }
// getActionConfig 获取 Helm action configuration // getActionConfig 获取 Helm action configuration
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, func(), error) { func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, error) {
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
// 创建临时 kubeconfig 文件 // 创建临时 kubeconfig 文件
kubeconfigContent := cluster.GetKubeConfig() kubeconfigContent := cluster.GetKubeConfig()
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*") tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to create temp dir: %w", err) return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() {
_ = os.RemoveAll(tmpDir)
} }
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig") kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil { if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
cleanup() return nil, fmt.Errorf("failed to write kubeconfig: %w", err)
return nil, nil, fmt.Errorf("failed to write kubeconfig: %w", err)
} }
// 使用 kubeconfig 初始化 action config // 使用 kubeconfig 初始化 action config
if err := actionConfig.Init( if err := actionConfig.Init(
&kubeconfigGetter{kubeconfigPath: kubeconfigPath, namespace: namespace}, &kubeconfigGetter{kubeconfigPath: kubeconfigPath},
namespace, namespace,
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
func(format string, v ...interface{}) { func(format string, v ...interface{}) {
// Log function // Log function
}, },
); err != nil { ); err != nil {
cleanup() return nil, fmt.Errorf("failed to initialize action config: %w", err)
return nil, nil, fmt.Errorf("failed to initialize action config: %w", err)
} }
return actionConfig, cleanup, nil return actionConfig, nil
} }
// kubeconfigGetter implements RESTClientGetter // kubeconfigGetter implements RESTClientGetter
type kubeconfigGetter struct { type kubeconfigGetter struct {
kubeconfigPath string kubeconfigPath string
namespace string
} }
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) { func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
@ -102,45 +96,42 @@ func (k *kubeconfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
} }
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
overrides := &clientcmd.ConfigOverrides{}
if k.namespace != "" {
overrides.Context = clientcmdapi.Context{Namespace: k.namespace}
}
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath}, &clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
overrides, &clientcmd.ConfigOverrides{},
) )
} }
// Install 安装 Helm Chart // Install 安装 Helm Chart
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error { func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace) actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
if err != nil { if err != nil {
return err return err
} }
defer cleanup()
install := action.NewInstall(actionConfig) install := action.NewInstall(actionConfig)
install.ReleaseName = instance.Name install.ReleaseName = instance.Name
install.Namespace = instance.Namespace install.Namespace = instance.Namespace
install.CreateNamespace = true install.CreateNamespace = true
install.Wait = true install.Wait = true
install.Timeout = helmOperationTimeout() install.Timeout = 1 * time.Minute
// 加载 Chart从本地路径或 OCI registry // 加载 Chart从本地路径或 OCI registry
// 这里简化处理,假设 chart 已经被拉取到本地
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version) chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
chart, err := loader.Load(chartPath) chart, err := loader.Load(chartPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to load chart: %w", err) 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) 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 { if err != nil {
return fmt.Errorf("failed to install release: %w", err) return fmt.Errorf("failed to install release: %w", err)
} }
log.Printf("[helm-install] step=done instance=%s revision=%d", instance.Name, rel.Version)
// 更新 revision状态由调用方根据操作结果设置 // 更新 revision状态由调用方根据操作结果设置
instance.Revision = rel.Version instance.Revision = rel.Version
@ -151,17 +142,15 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
// Upgrade 升级 Helm Release // Upgrade 升级 Helm Release
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error { func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace) actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
if err != nil { if err != nil {
return err return err
} }
defer cleanup()
upgrade := action.NewUpgrade(actionConfig) upgrade := action.NewUpgrade(actionConfig)
upgrade.Namespace = instance.Namespace upgrade.Namespace = instance.Namespace
upgrade.ReuseValues = true
upgrade.Wait = true upgrade.Wait = true
upgrade.Timeout = helmOperationTimeout() upgrade.Timeout = 5 * time.Minute
// 加载 Chart // 加载 Chart
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version) chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
@ -186,15 +175,14 @@ func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, insta
// Uninstall 卸载 Helm Release // Uninstall 卸载 Helm Release
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error { func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return err return err
} }
defer cleanup()
uninstall := action.NewUninstall(actionConfig) uninstall := action.NewUninstall(actionConfig)
uninstall.Wait = true uninstall.Wait = true
uninstall.Timeout = helmOperationTimeout() uninstall.Timeout = 5 * time.Minute
_, err = uninstall.Run(releaseName) _, err = uninstall.Run(releaseName)
if err != nil { if err != nil {
@ -209,16 +197,15 @@ func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, rel
// Rollback 回滚 Helm Release // Rollback 回滚 Helm Release
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error { func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return err return err
} }
defer cleanup()
rollback := action.NewRollback(actionConfig) rollback := action.NewRollback(actionConfig)
rollback.Version = revision rollback.Version = revision
rollback.Wait = true rollback.Wait = true
rollback.Timeout = helmOperationTimeout() rollback.Timeout = 5 * time.Minute
if err := rollback.Run(releaseName); err != nil { if err := rollback.Run(releaseName); err != nil {
return fmt.Errorf("failed to rollback release: %w", err) return fmt.Errorf("failed to rollback release: %w", err)
@ -227,25 +214,12 @@ func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, rele
return nil 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 状态 // GetStatus 获取 Release 状态
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) { func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer cleanup()
status := action.NewStatus(actionConfig) status := action.NewStatus(actionConfig)
rel, err := status.Run(releaseName) rel, err := status.Run(releaseName)
@ -258,11 +232,10 @@ func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, rel
// GetHistory 获取 Release 历史 // GetHistory 获取 Release 历史
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) { func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer cleanup()
history := action.NewHistory(actionConfig) history := action.NewHistory(actionConfig)
history.Max = 256 history.Max = 256
@ -289,11 +262,10 @@ func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, re
// List 列出集群中的所有 Releases // List 列出集群中的所有 Releases
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) { func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer cleanup()
list := action.NewList(actionConfig) list := action.NewList(actionConfig)
if namespace == "" { if namespace == "" {
@ -315,14 +287,12 @@ func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespac
// GetValues 获取 Release 的 values // GetValues 获取 Release 的 values
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) { func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace) actionConfig, err := h.getActionConfig(cluster, namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer cleanup()
getValues := action.NewGetValues(actionConfig) getValues := action.NewGetValues(actionConfig)
getValues.AllValues = true
values, err := getValues.Run(releaseName) values, err := getValues.Run(releaseName)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get values: %w", err) return nil, fmt.Errorf("failed to get values: %w", err)
@ -331,21 +301,6 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel
return values, nil 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 // convertReleaseToInstance 转换 Helm Release 为 Instance
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance { func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
return &entity.Instance{ return &entity.Instance{

View File

@ -1,45 +0,0 @@
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)
}
}

View File

@ -1,374 +0,0 @@
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 ""
}
}

View File

@ -1,134 +0,0 @@
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
}

View File

@ -1,388 +0,0 @@
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(&current.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(&current.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(&current.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(&current.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")
}

View File

@ -1,172 +0,0 @@
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
}

View File

@ -1,36 +0,0 @@
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()
}

View File

@ -13,7 +13,7 @@ import (
// OCIClientMock OCI Registry 客户端 Mock 实现 // OCIClientMock OCI Registry 客户端 Mock 实现
type OCIClientMock struct { type OCIClientMock struct {
// Mock 数据存储 // Mock 数据存储
repositories map[string][]string // registryID -> []repositoryName repositories map[string][]string // registryID -> []repositoryName
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
} }
@ -42,14 +42,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
// vllm-serve artifacts (OCI 格式的 Helm Chart) // vllm-serve artifacts (OCI 格式的 Helm Chart)
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{ c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "charts/vllm-serve", Repository: "charts/vllm-serve",
Tag: "0.1.0", Tag: "0.1.0",
Digest: "sha256:abc123def456", Digest: "sha256:abc123def456",
Type: entity.ArtifactTypeChart, Type: entity.ArtifactTypeChart,
Size: 12345678, Size: 12345678,
MediaType: "application/vnd.oci.image.manifest.v1+json", MediaType: "application/vnd.oci.image.manifest.v1+json",
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "vllm-serve", "org.opencontainers.image.title": "vllm-serve",
"org.opencontainers.image.version": "0.1.0", "org.opencontainers.image.version": "0.1.0",
@ -57,14 +57,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
CreatedAt: time.Now().Add(-24 * time.Hour), CreatedAt: time.Now().Add(-24 * time.Hour),
}, },
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "charts/vllm-serve", Repository: "charts/vllm-serve",
Tag: "0.2.0", Tag: "0.2.0",
Digest: "sha256:xyz789uvw012", Digest: "sha256:xyz789uvw012",
Type: entity.ArtifactTypeChart, Type: entity.ArtifactTypeChart,
Size: 13456789, Size: 13456789,
MediaType: "application/vnd.oci.image.manifest.v1+json", MediaType: "application/vnd.oci.image.manifest.v1+json",
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "vllm-serve", "org.opencontainers.image.title": "vllm-serve",
"org.opencontainers.image.version": "0.2.0", "org.opencontainers.image.version": "0.2.0",
@ -76,14 +76,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
// nginx artifacts (OCI 格式的 Helm Chart) // nginx artifacts (OCI 格式的 Helm Chart)
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{ c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "charts/nginx", Repository: "charts/nginx",
Tag: "1.0.0", Tag: "1.0.0",
Digest: "sha256:nginx123456", Digest: "sha256:nginx123456",
Type: entity.ArtifactTypeChart, Type: entity.ArtifactTypeChart,
Size: 5678901, Size: 5678901,
MediaType: "application/vnd.oci.image.manifest.v1+json", MediaType: "application/vnd.oci.image.manifest.v1+json",
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "nginx", "org.opencontainers.image.title": "nginx",
}, },
@ -94,14 +94,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
// redis artifacts (OCI 格式的 Helm Chart) // redis artifacts (OCI 格式的 Helm Chart)
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{ c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "charts/redis", Repository: "charts/redis",
Tag: "6.2.0", Tag: "6.2.0",
Digest: "sha256:redis789abc", Digest: "sha256:redis789abc",
Type: entity.ArtifactTypeChart, Type: entity.ArtifactTypeChart,
Size: 8901234, Size: 8901234,
MediaType: "application/vnd.oci.image.manifest.v1+json", MediaType: "application/vnd.oci.image.manifest.v1+json",
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "redis", "org.opencontainers.image.title": "redis",
"org.opencontainers.image.version": "6.2.0", "org.opencontainers.image.version": "6.2.0",
@ -113,14 +113,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
// alpine artifacts (Docker Image) // alpine artifacts (Docker Image)
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{ c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "library/alpine", Repository: "library/alpine",
Tag: "3.18", Tag: "3.18",
Digest: "sha256:alpine123", Digest: "sha256:alpine123",
Type: entity.ArtifactTypeImage, Type: entity.ArtifactTypeImage,
Size: 2345678, Size: 2345678,
MediaType: "application/vnd.docker.distribution.manifest.v2+json", MediaType: "application/vnd.docker.distribution.manifest.v2+json",
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "alpine", "org.opencontainers.image.title": "alpine",
"org.opencontainers.image.version": "3.18", "org.opencontainers.image.version": "3.18",
@ -128,14 +128,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
CreatedAt: time.Now().Add(-96 * time.Hour), CreatedAt: time.Now().Add(-96 * time.Hour),
}, },
{ {
RegistryID: registryID, RegistryID: registryID,
Repository: "library/alpine", Repository: "library/alpine",
Tag: "latest", Tag: "latest",
Digest: "sha256:alpine456", Digest: "sha256:alpine456",
Type: entity.ArtifactTypeImage, Type: entity.ArtifactTypeImage,
Size: 2456789, Size: 2456789,
MediaType: "application/vnd.docker.distribution.manifest.v2+json", MediaType: "application/vnd.docker.distribution.manifest.v2+json",
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
Annotations: map[string]string{ Annotations: map[string]string{
"org.opencontainers.image.title": "alpine", "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, artifactType string) ([]string, error) { func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
// Check if we have cached data for this registry // Check if we have cached data for this registry
repos, exists := c.repositories[registry.ID] repos, exists := c.repositories[registry.ID]
if !exists { if !exists {
@ -160,16 +160,6 @@ func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.R
// Also initialize artifacts for this registry // Also initialize artifacts for this registry
c.initArtifactsForRegistry(registry.ID) 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 return repos, nil
} }
@ -272,15 +262,32 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
return mockSchema, nil return mockSchema, nil
} }
func (c *OCIClientMock) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) { func (c *OCIClientMock) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
artifact, err := c.GetArtifact(ctx, registry, repository, reference) artifact, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil { if err != nil {
return "", err return "", err
} }
if !artifact.IsChart() { if !artifact.IsChart() {
return "", fmt.Errorf("not a helm chart") return "", fmt.Errorf("not a helm chart")
} }
return "replicaCount: 1\nimage:\n repository: nginx\n tag: latest\nservice:\n type: ClusterIP\n", nil
// 返回 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
} }
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error { func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
@ -302,3 +309,4 @@ func (c *OCIClientMock) CheckHealth(ctx context.Context, registry *entity.Regist
// Mock 实现,总是返回健康 // Mock 实现,总是返回健康
return nil return nil
} }

View File

@ -7,14 +7,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
"time"
"github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/domain/repository"
@ -29,30 +26,6 @@ type OCIClient struct {
httpClient *http.Client 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 客户端 // NewOCIClient 创建真实的 OCI 客户端
func NewOCIClient() repository.OCIClient { func NewOCIClient() repository.OCIClient {
return &OCIClient{ return &OCIClient{
@ -71,13 +44,26 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
return nil, fmt.Errorf("failed to create registry client: %w", err) return nil, fmt.Errorf("failed to create registry client: %w", err)
} }
// 设置认证 // 设置认证 - 优先使用 registry 自己的凭证,否则使用 .env 中的默认凭证
if reg.Username != "" && reg.Password != "" { 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 != "" {
registry.Client = &auth.Client{ registry.Client = &auth.Client{
Client: c.httpClient, Client: c.httpClient,
Credential: auth.StaticCredential(registryURL, auth.Credential{ Credential: auth.StaticCredential(registryURL, auth.Credential{
Username: reg.Username, Username: username,
Password: reg.Password, Password: password,
}), }),
} }
} }
@ -88,325 +74,154 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
return registry, nil return registry, nil
} }
// ListRepositories 列出 Registry 中的 repositories. // ListRepositories 列出 Registry 中的所有 repositories
// Harbor registry 优先使用 Harbor v2.0 API避免 robot 账号依赖 /v2/_catalog 全局权限。 // 优先使用 OCI _catalog API失败时回退到 Harbor REST API v2
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) { func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
repositories, harborErr := c.listHarborRepositories(ctx, registry, artifactType) repositories := make([]string, 0)
if harborErr == nil {
// 尝试 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))
return repositories, nil return repositories, nil
} }
repositories, catalogErr := c.listOCIRepositories(ctx, registry) // 回退: 使用 Harbor REST API v2
if catalogErr != nil { log.Printf("[Harbor Fallback] OCI failed (err=%v, repos=%d), checking if Harbor...", err, len(repositories))
return nil, fmt.Errorf("failed to list repositories via Harbor API: %v; OCI catalog fallback also failed: %w", harborErr, catalogErr) log.Printf("[Harbor Fallback] registry.URL=%s, contains 'harbor'=%v", registry.URL, strings.Contains(registry.URL, "harbor"))
}
if strings.EqualFold(strings.TrimSpace(artifactType), "chart") { if strings.Contains(registry.URL, "harbor") {
chartRepos := make([]string, 0) log.Printf("[Harbor Fallback] Yes, this is Harbor! Calling Harbor REST API...")
for _, repo := range repositories { repos, fallbackErr := c.listHarborRepositories(registry)
artifacts, err := c.ListArtifacts(ctx, registry, repo, "chart") log.Printf("[Harbor Fallback] Got %d repos, err=%v", len(repos), fallbackErr)
if err == nil && len(artifacts) > 0 { if fallbackErr == nil && len(repos) > 0 {
chartRepos = append(chartRepos, repo) log.Printf("[Harbor Fallback] Returning %d repos from Harbor API", len(repos))
} return repos, nil
} }
return chartRepos, nil if err != nil {
return nil, fmt.Errorf("failed to list repositories: %w", err)
}
return nil, fallbackErr
} }
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 { if err != nil {
return nil, fmt.Errorf("failed to list repositories: %w", err) return nil, fmt.Errorf("failed to list repositories: %w", err)
} }
return repositories, nil return repositories, nil
} }
func (c *OCIClient) listHarborRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) { // listHarborRepositories 使用 Harbor REST API v2 获取仓库列表
projects, err := c.harborListProjects(ctx, registry) 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)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.SetBasicAuth(username, password)
repositorySet := make(map[string]struct{})
chartOnly := strings.EqualFold(strings.TrimSpace(artifactType), "chart") || strings.TrimSpace(artifactType) == ""
for _, project := range projects {
projectName := strings.TrimSpace(project.Name)
if projectName == "" {
continue
}
repositories, err := c.harborListProjectRepositories(ctx, registry, projectName)
if err != nil {
return nil, err
}
for _, harborRepo := range repositories {
repoName := normalizeHarborRepositoryName(projectName, harborRepo.Name)
if repoName == "" {
continue
}
if chartOnly {
artifacts, err := c.listHarborArtifacts(ctx, registry, repoName, "chart")
if err != nil || len(artifacts) == 0 {
continue
}
}
repositorySet[repoName] = struct{}{}
}
}
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) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, -1, fmt.Errorf("Harbor API request failed: %w", err) return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) if resp.StatusCode != http.StatusOK {
if readErr != nil { return nil, fmt.Errorf("failed to list projects: status %d", resp.StatusCode)
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 var projects []struct {
if value := strings.TrimSpace(resp.Header.Get("X-Total-Count")); value != "" { Name string `json:"name"`
if parsed, err := strconv.Atoi(value); err == nil { }
total = parsed if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
return nil, err
}
repositories := make([]string, 0)
pageSize := 100
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)
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("[listHarborRepositories] page %d: Do error: %v", page, err)
break
}
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
}
var repos []struct {
Name string `json:"name"`
}
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++
} }
} }
return body, total, nil
}
func harborBaseURL(registry *entity.Registry) (string, error) { log.Printf("[listHarborRepositories] Total repos collected: %d", len(repositories))
rawURL := strings.TrimSpace(registry.URL) return repositories, nil
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 // ListArtifacts 列出指定 repository 的所有 artifacts
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤 // mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) { 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) reg, err := c.getRegistry(registry)
if err != nil { if err != nil {
return nil, err return nil, err
@ -693,19 +508,8 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
return "", entity.ErrValuesSchemaNotFound return "", entity.ErrValuesSchemaNotFound
} }
// GetValuesYAML 获取 Helm Chart 包内原始 values.yaml // GetValues 获取 Helm Chart values.yaml
func (c *OCIClient) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) { func (c *OCIClient) GetValues(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) reg, err := c.getRegistry(registry)
if err != nil { if err != nil {
return "", err return "", err
@ -716,6 +520,7 @@ func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry
return "", fmt.Errorf("failed to get repository: %w", err) return "", fmt.Errorf("failed to get repository: %w", err)
} }
// 解析 reference (tag 或 digest)
desc, err := repo.Resolve(ctx, reference) desc, err := repo.Resolve(ctx, reference)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve artifact: %w", err) return "", fmt.Errorf("failed to resolve artifact: %w", err)
@ -737,6 +542,7 @@ func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry
return "", fmt.Errorf("failed to unmarshal manifest: %w", err) return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
} }
// 查找 Helm Chart layertar+gzip 包含 chart 内容)并从中读取 values.yaml
var chartLayer *ocispec.Descriptor var chartLayer *ocispec.Descriptor
for i := range manifest.Layers { for i := range manifest.Layers {
layer := manifest.Layers[i] layer := manifest.Layers[i]
@ -746,9 +552,11 @@ func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry
break break
} }
} }
if chartLayer == nil { if chartLayer == nil {
return "", fmt.Errorf("helm chart layer not found in manifest") return "", entity.ErrValuesNotFound
} }
if chartLayer.Digest == "" { if chartLayer.Digest == "" {
return "", fmt.Errorf("chart layer digest is empty") return "", fmt.Errorf("chart layer digest is empty")
} }
@ -769,8 +577,6 @@ func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry
defer gzipReader.Close() defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader) tarReader := tar.NewReader(gzipReader)
bestDepth := int(^uint(0) >> 1)
var bestData []byte
for { for {
header, err := tarReader.Next() header, err := tarReader.Next()
if err == io.EOF { if err == io.EOF {
@ -779,25 +585,26 @@ func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read chart archive: %w", err) return "", fmt.Errorf("failed to read chart archive: %w", err)
} }
if header.Typeflag != tar.TypeReg { if header.Typeflag != tar.TypeReg {
continue continue
} }
if strings.HasSuffix(header.Name, filename) {
// 查找 values.yaml 文件(可能在 chart 根目录或子目录中)
// 通常路径格式为: {chart-name}/values.yaml
if strings.HasSuffix(header.Name, "values.yaml") {
data, err := io.ReadAll(tarReader) data, err := io.ReadAll(tarReader)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read %s: %w", filename, err) return "", fmt.Errorf("failed to read values.yaml: %w", err)
} }
depth := strings.Count(strings.Trim(header.Name, "/"), "/") if len(data) == 0 {
if depth < bestDepth { return "", entity.ErrValuesNotFound
bestDepth = depth
bestData = data
} }
return string(data), nil
} }
} }
if len(bestData) > 0 {
return string(bestData), nil return "", entity.ErrValuesNotFound
}
return "", fmt.Errorf("%s not found in chart", filename)
} }
// PullArtifact 下载 artifact 到本地 // PullArtifact 下载 artifact 到本地

View File

@ -114,6 +114,34 @@ func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, er
return clusters, nil 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 的敏感数据 // encryptCluster 加密 Cluster 的敏感数据
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster { func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
encrypted := *cluster // 复制 encrypted := *cluster // 复制
@ -171,3 +199,4 @@ func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.
return &decrypted return &decrypted
} }

View File

@ -110,3 +110,18 @@ func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance,
return instances, nil 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
}

View File

@ -134,3 +134,4 @@ func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *ent
return &decrypted return &decrypted
} }

View File

@ -0,0 +1,117 @@
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
}

View File

@ -96,3 +96,32 @@ func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
return users, nil 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
}

View File

@ -1,162 +0,0 @@
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] = &copy
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 &copy, 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 &copy, 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] = &copy
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, &copy)
}
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)] = &copy
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 &copy, 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, &copy)
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, &copy)
if limit > 0 && len(result) >= limit {
break
}
}
}
return result, nil
}

View File

@ -0,0 +1,200 @@
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
}

View File

@ -0,0 +1,253 @@
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
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -12,33 +13,61 @@ import (
"github.com/ocdp/cluster-service/internal/pkg/crypto" "github.com/ocdp/cluster-service/internal/pkg/crypto"
) )
// ClusterRepository PostgreSQL 集群仓储实现
type ClusterRepository struct { type ClusterRepository struct {
db *DB db *DB
encryptor crypto.Encryptor encryptor crypto.Encryptor
} }
// NewClusterRepository 创建 PostgreSQL 集群仓储
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository { 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 { func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
if cluster.ID == "" { if cluster.ID == "" {
cluster.ID = uuid.New().String() cluster.ID = uuid.New().String()
} }
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
if err != nil { // 设置默认值
return err if cluster.IsolationMode == "" {
cluster.IsolationMode = entity.IsolationModeNamespace
} }
// 加密敏感数据
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
if err != nil {
return fmt.Errorf("failed to encrypt CA data: %w", 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 := ` query := `
INSERT INTO clusters 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)
(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, $15)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
` `
_, err = r.db.conn.ExecContext(ctx, query, _, err = r.db.conn.ExecContext(ctx, query,
cluster.ID, cluster.ID,
cluster.WorkspaceID, cluster.WorkspaceID,
cluster.OwnerID, cluster.OwnerID,
cluster.Visibility,
cluster.Name, cluster.Name,
cluster.Host, cluster.Host,
encryptedCAData, encryptedCAData,
@ -46,139 +75,35 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData, encryptedKeyData,
encryptedToken, encryptedToken,
cluster.Description, cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace, cluster.DefaultNamespace,
cluster.IsShared,
cluster.CreatedAt, cluster.CreatedAt,
cluster.UpdatedAt, cluster.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create cluster: %w", err) return fmt.Errorf("failed to create cluster: %w", err)
} }
return nil return nil
} }
// GetByID 根据 ID 获取集群
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) { func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
return r.get(ctx, "id = $1", id)
}
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
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 %s
`, where)
rows, err := r.db.conn.QueryContext(ctx, query, arg)
if err != nil {
return nil, fmt.Errorf("failed to get cluster: %w", err)
}
defer rows.Close()
if !rows.Next() {
return nil, entity.ErrClusterNotFound
}
cluster, err := r.scanCluster(rows)
if err != nil {
return nil, err
}
return cluster, nil
}
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
cluster.UpdatedAt = time.Now()
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
if err != nil {
return err
}
query := ` query := `
UPDATE clusters 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
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,
encryptedCertData,
encryptedKeyData,
encryptedToken,
cluster.Description,
cluster.DefaultNamespace,
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
}
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
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
}
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
query := `
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 FROM clusters
ORDER BY created_at DESC WHERE id = $1
` `
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list clusters: %w", err)
}
defer rows.Close()
clusters := make([]*entity.Cluster, 0)
for rows.Next() {
cluster, err := r.scanCluster(rows)
if err != nil {
return nil, err
}
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{} cluster := &entity.Cluster{}
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
var defaultNamespace sql.NullString
err := scanner.Scan( err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&cluster.ID, &cluster.ID,
&cluster.WorkspaceID, &cluster.WorkspaceID,
&cluster.OwnerID, &cluster.OwnerID,
&cluster.Visibility,
&cluster.Name, &cluster.Name,
&cluster.Host, &cluster.Host,
&encryptedCAData, &encryptedCAData,
@ -186,57 +111,289 @@ func (r *ClusterRepository) scanCluster(scanner clusterScanner) (*entity.Cluster
&encryptedKeyData, &encryptedKeyData,
&encryptedToken, &encryptedToken,
&cluster.Description, &cluster.Description,
&defaultNamespace, &cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt, &cluster.CreatedAt,
&cluster.UpdatedAt, &cluster.UpdatedAt,
) )
if err == sql.ErrNoRows {
return nil, entity.ErrClusterNotFound
}
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan cluster: %w", err) return nil, fmt.Errorf("failed to get 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)
} }
// 解密敏感数据(检测 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 cluster, nil
} }
func (r *ClusterRepository) encryptClusterSecrets(cluster *entity.Cluster) (string, string, string, string, error) { // GetByName 根据名称获取集群
ca, err := r.encryptor.Encrypt(cluster.CAData) func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
if err != nil { query := `
return "", "", "", "", fmt.Errorf("failed to encrypt CA data: %w", err) 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 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
} }
cert, err := r.encryptor.Encrypt(cluster.CertData)
if err != nil { if err != nil {
return "", "", "", "", fmt.Errorf("failed to encrypt cert data: %w", err) return nil, fmt.Errorf("failed to get cluster: %w", err)
} }
key, err := r.encryptor.Encrypt(cluster.KeyData)
if err != nil { // 解密敏感数据(检测 kubeconfig 格式则跳过解密)
return "", "", "", "", fmt.Errorf("failed to encrypt key data: %w", err) cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
} cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
token, err := r.encryptor.Encrypt(cluster.Token) cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
if err != nil { cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
return "", "", "", "", fmt.Errorf("failed to encrypt token: %w", err)
} return cluster, nil
return ca, cert, key, token, nil
} }
func decryptMaybe(encryptor crypto.Encryptor, value string) (string, error) { // decryptIfNeeded 解密数据。如果数据以 "apiVersion:" 或 "kind:" 开头kubeconfig 格式),
if value == "" { // 则跳过解密直接返回原值。
return "", nil func (r *ClusterRepository) decryptIfNeeded(data string, fieldName string) string {
if data == "" {
return ""
} }
return encryptor.Decrypt(value) // 检测 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)
if err != nil {
return fmt.Errorf("failed to encrypt CA data: %w", 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
`
result, err := r.db.conn.ExecContext(ctx, query,
cluster.Name,
cluster.Host,
encryptedCAData,
encryptedCertData,
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)
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
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,
)
if err != nil {
return nil, fmt.Errorf("failed to scan cluster: %w", 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
}

View File

@ -53,69 +53,21 @@ func (db *DB) GetConn() *sql.DB {
// InitSchema 初始化数据库 schema // InitSchema 初始化数据库 schema
func (db *DB) InitSchema() error { func (db *DB) InitSchema() error {
schema := ` 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 表 -- Users 表
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
email VARCHAR(255) 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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_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 表 -- Clusters 表
CREATE TABLE IF NOT EXISTS clusters ( CREATE TABLE IF NOT EXISTS clusters (
id VARCHAR(36) PRIMARY KEY, 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, name VARCHAR(255) NOT NULL UNIQUE,
host TEXT NOT NULL, host TEXT NOT NULL,
ca_data TEXT, ca_data TEXT,
@ -123,29 +75,15 @@ func (db *DB) InitSchema() error {
key_data TEXT, key_data TEXT,
token TEXT, token TEXT,
description TEXT, description TEXT,
default_namespace VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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_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 表 -- Registries 表
CREATE TABLE IF NOT EXISTS registries ( CREATE TABLE IF NOT EXISTS registries (
id VARCHAR(36) PRIMARY KEY, 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, name VARCHAR(255) NOT NULL UNIQUE,
url TEXT NOT NULL, url TEXT NOT NULL,
description TEXT, description TEXT,
@ -156,22 +94,11 @@ func (db *DB) InitSchema() error {
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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_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 表 -- Instances 表
CREATE TABLE IF NOT EXISTS instances ( CREATE TABLE IF NOT EXISTS instances (
id VARCHAR(36) PRIMARY KEY, 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, cluster_id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
namespace VARCHAR(255) NOT NULL, namespace VARCHAR(255) NOT NULL,
@ -194,63 +121,61 @@ func (db *DB) InitSchema() error {
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace) 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_cluster ON instances(cluster_id);
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_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_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);
CREATE TABLE IF NOT EXISTS workspace_cluster_bindings ( -- Storage Backends
id VARCHAR(36) PRIMARY KEY, CREATE TABLE IF NOT EXISTS storage_backends (
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 (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 TABLE IF NOT EXISTS audit_logs (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36), workspace_id VARCHAR(36),
user_id VARCHAR(36), owner_id VARCHAR(36),
action VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL,
resource_type VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL,
resource_id VARCHAR(36), config JSONB NOT NULL,
resource_name VARCHAR(255), description TEXT,
details JSONB, is_default BOOLEAN DEFAULT FALSE,
ip_address VARCHAR(50), is_shared BOOLEAN DEFAULT FALSE,
user_agent TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_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); 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,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (chart_reference_id, name, version)
);
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);
` `
_, err := db.conn.Exec(schema) _, err := db.conn.Exec(schema)

View File

@ -12,32 +12,37 @@ import (
"github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/domain/repository"
) )
// InstanceRepository PostgreSQL 实例仓储实现
type InstanceRepository struct { type InstanceRepository struct {
db *DB db *DB
} }
// NewInstanceRepository 创建 PostgreSQL 实例仓储
func NewInstanceRepository(db *DB) repository.InstanceRepository { func NewInstanceRepository(db *DB) repository.InstanceRepository {
return &InstanceRepository{db: db} return &InstanceRepository{db: db}
} }
// Create 创建实例
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error { func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
if instance.ID == "" { if instance.ID == "" {
instance.ID = uuid.New().String() instance.ID = uuid.New().String()
} }
// 将 Values 转换为 JSON
valuesJSON, err := json.Marshal(instance.Values) valuesJSON, err := json.Marshal(instance.Values)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal values: %w", err) return fmt.Errorf("failed to marshal values: %w", err)
} }
query := ` query := `
INSERT INTO instances INSERT INTO instances (id, cluster_id, name, namespace, registry_id, repository, chart, version,
(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,
description, values, values_yaml, status, status_reason, last_operation, last_error, revision, created_at, updated_at) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
` `
_, err = r.db.conn.ExecContext(ctx, query, _, err = r.db.conn.ExecContext(ctx, query,
instance.ID, instance.ID,
instance.WorkspaceID,
instance.OwnerID,
instance.ClusterID, instance.ClusterID,
instance.Name, instance.Name,
instance.Namespace, instance.Namespace,
@ -56,164 +61,24 @@ func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instan
instance.CreatedAt, instance.CreatedAt,
instance.UpdatedAt, instance.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create instance: %w", err) return fmt.Errorf("failed to create instance: %w", err)
} }
return nil return nil
} }
// GetByID 根据 ID 获取实例
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) { func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
return r.get(ctx, "id = $1", id)
}
func (r *InstanceRepository) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
query := ` query := `
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version, SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
description, values, values_yaml, status, status_reason, last_operation, last_error, description, values, values_yaml, status, status_reason, last_operation, last_error,
revision, created_at, updated_at revision, created_at, updated_at
FROM instances FROM instances
WHERE cluster_id = $1 AND name = $2 WHERE id = $1
` `
rows, err := r.db.conn.QueryContext(ctx, query, clusterID, name)
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)
}
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)
}
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
instance.UpdatedAt = time.Now()
valuesJSON, err := json.Marshal(instance.Values)
if err != nil {
return fmt.Errorf("failed to marshal values: %w", err)
}
query := `
UPDATE instances
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,
instance.RegistryID,
instance.Repository,
instance.Chart,
instance.Version,
instance.Description,
valuesJSON,
instance.ValuesYAML,
instance.Status,
instance.StatusReason,
instance.LastOperation,
instance.LastError,
instance.Revision,
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
}
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
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
}
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
return r.list(ctx, "WHERE cluster_id = $1", clusterID)
}
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, 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
`
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, err := r.scanInstance(rows)
if err != nil {
return nil, err
}
instances = append(instances, instance)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return instances, nil
}
type instanceScanner interface {
Scan(dest ...interface{}) error
}
func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Instance, error) {
instance := &entity.Instance{} instance := &entity.Instance{}
var ( var (
valuesJSON []byte valuesJSON []byte
@ -221,10 +86,9 @@ func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Inst
lastOperation sql.NullString lastOperation sql.NullString
lastError sql.NullString lastError sql.NullString
) )
err := scanner.Scan(
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&instance.ID, &instance.ID,
&instance.WorkspaceID,
&instance.OwnerID,
&instance.ClusterID, &instance.ClusterID,
&instance.Name, &instance.Name,
&instance.Namespace, &instance.Namespace,
@ -243,14 +107,21 @@ func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Inst
&instance.CreatedAt, &instance.CreatedAt,
&instance.UpdatedAt, &instance.UpdatedAt,
) )
if err != nil {
return nil, fmt.Errorf("failed to scan instance: %w", err) 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 len(valuesJSON) > 0 {
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil { if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err) return nil, fmt.Errorf("failed to unmarshal values: %w", err)
} }
} }
if statusReason.Valid { if statusReason.Valid {
instance.StatusReason = statusReason.String instance.StatusReason = statusReason.String
} }
@ -260,5 +131,405 @@ func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Inst
if lastError.Valid { if lastError.Valid {
instance.LastError = lastError.String instance.LastError = lastError.String
} }
return instance, nil return instance, nil
} }
// 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,
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
}
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
}
// 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
`
result, err := r.db.conn.ExecContext(ctx, query,
instance.ClusterID,
instance.Name,
instance.Namespace,
instance.RegistryID,
instance.Repository,
instance.Chart,
instance.Version,
instance.Description,
valuesJSON,
instance.ValuesYAML,
instance.Status,
instance.StatusReason,
instance.LastOperation,
instance.LastError,
instance.Revision,
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)
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
}
// List 列出所有实例
func (r *InstanceRepository) List(ctx context.Context) ([]*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
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
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
}
// 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
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to get instances by workspace: %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 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)
}
return instances, nil
}

View File

@ -0,0 +1,212 @@
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(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.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(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.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(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.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
}

View File

@ -12,32 +12,39 @@ import (
"github.com/ocdp/cluster-service/internal/pkg/crypto" "github.com/ocdp/cluster-service/internal/pkg/crypto"
) )
// RegistryRepository PostgreSQL Registry 仓储实现
type RegistryRepository struct { type RegistryRepository struct {
db *DB db *DB
encryptor crypto.Encryptor encryptor crypto.Encryptor
} }
// NewRegistryRepository 创建 PostgreSQL Registry 仓储
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository { 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 { func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
if registry.ID == "" { if registry.ID == "" {
registry.ID = uuid.New().String() registry.ID = uuid.New().String()
} }
// 加密密码
encryptedPassword, err := r.encryptor.Encrypt(registry.Password) encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
if err != nil { if err != nil {
return fmt.Errorf("failed to encrypt password: %w", err) return fmt.Errorf("failed to encrypt password: %w", err)
} }
query := ` query := `
INSERT INTO registries (id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at) INSERT INTO registries (id, name, url, description, username, password, insecure, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
` `
_, err = r.db.conn.ExecContext(ctx, query, _, err = r.db.conn.ExecContext(ctx, query,
registry.ID, registry.ID,
registry.WorkspaceID,
registry.OwnerID,
registry.Visibility,
registry.Name, registry.Name,
registry.URL, registry.URL,
registry.Description, registry.Description,
@ -47,57 +54,120 @@ func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Regist
registry.CreatedAt, registry.CreatedAt,
registry.UpdatedAt, registry.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create registry: %w", err) return fmt.Errorf("failed to create registry: %w", err)
} }
return nil return nil
} }
// GetByID 根据 ID 获取 Registry
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) { func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
return r.get(ctx, "id = $1", id) query := `
} SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
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 FROM registries
WHERE %s WHERE id = $1
`, where) `
rows, err := r.db.conn.QueryContext(ctx, query, arg)
registry := &entity.Registry{}
var encryptedPassword, workspaceID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&registry.ID,
&workspaceID,
&ownerID,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.IsShared,
&registry.CreatedAt,
&registry.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrRegistryNotFound
}
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get registry: %w", err) return nil, fmt.Errorf("failed to get registry: %w", err)
} }
defer rows.Close()
if !rows.Next() { registry.WorkspaceID = workspaceID.String
return nil, entity.ErrRegistryNotFound registry.OwnerID = ownerID.String
}
registry, err := r.scanRegistry(rows) // 解密密码(如果失败则保持为空,与 List 行为一致)
if err != nil { if encryptedPassword.Valid {
return nil, err registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
} }
return registry, nil return registry, nil
} }
// 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
FROM registries
WHERE name = $1
`
registry := &entity.Registry{}
var encryptedPassword, workspaceID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&registry.ID,
&workspaceID,
&ownerID,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.IsShared,
&registry.CreatedAt,
&registry.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
}
// Update 更新 Registry
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error { func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
registry.UpdatedAt = time.Now() registry.UpdatedAt = time.Now()
// 加密密码
encryptedPassword, err := r.encryptor.Encrypt(registry.Password) encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
if err != nil { if err != nil {
return fmt.Errorf("failed to encrypt password: %w", err) return fmt.Errorf("failed to encrypt password: %w", err)
} }
query := ` query := `
UPDATE registries UPDATE registries
SET workspace_id = $1, owner_id = $2, visibility = $3, name = $4, url = $5, SET name = $1, url = $2, description = $3, username = $4, password = $5,
description = $6, username = $7, password = $8, insecure = $9, updated_at = $10 insecure = $6, updated_at = $7
WHERE id = $11 WHERE id = $8
` `
result, err := r.db.conn.ExecContext(ctx, query, result, err := r.db.conn.ExecContext(ctx, query,
registry.WorkspaceID,
registry.OwnerID,
registry.Visibility,
registry.Name, registry.Name,
registry.URL, registry.URL,
registry.Description, registry.Description,
@ -107,86 +177,97 @@ func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Regist
registry.UpdatedAt, registry.UpdatedAt,
registry.ID, registry.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to update registry: %w", err) return fmt.Errorf("failed to update registry: %w", err)
} }
rows, err := result.RowsAffected() rows, err := result.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err) return fmt.Errorf("failed to get affected rows: %w", err)
} }
if rows == 0 { if rows == 0 {
return entity.ErrRegistryNotFound return entity.ErrRegistryNotFound
} }
return nil return nil
} }
// Delete 删除 Registry
func (r *RegistryRepository) Delete(ctx context.Context, id string) error { func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM registries WHERE id = $1`, id) query := `DELETE FROM registries WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete registry: %w", err) return fmt.Errorf("failed to delete registry: %w", err)
} }
rows, err := result.RowsAffected() rows, err := result.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err) return fmt.Errorf("failed to get affected rows: %w", err)
} }
if rows == 0 { if rows == 0 {
return entity.ErrRegistryNotFound return entity.ErrRegistryNotFound
} }
return nil return nil
} }
// List 列出所有 Registries
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) { func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
query := ` query := `
SELECT id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
FROM registries FROM registries
ORDER BY created_at DESC ORDER BY created_at DESC
` `
rows, err := r.db.conn.QueryContext(ctx, query) rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list registries: %w", err) return nil, fmt.Errorf("failed to list registries: %w", err)
} }
defer rows.Close() defer rows.Close()
registries := make([]*entity.Registry, 0) registries := make([]*entity.Registry, 0)
for rows.Next() { for rows.Next() {
registry, err := r.scanRegistry(rows) registry := &entity.Registry{}
var encryptedPassword, workspaceID, ownerID sql.NullString
err := rows.Scan(
&registry.ID,
&workspaceID,
&ownerID,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.IsShared,
&registry.CreatedAt,
&registry.UpdatedAt,
)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to scan registry: %w", err)
} }
// 处理 NULL 值
registry.WorkspaceID = workspaceID.String
registry.OwnerID = ownerID.String
// 解密密码
if encryptedPassword.Valid {
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
}
registries = append(registries, registry) registries = append(registries, registry)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err) return nil, fmt.Errorf("rows iteration error: %w", err)
} }
return registries, nil 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(
&registry.ID,
&registry.WorkspaceID,
&registry.OwnerID,
&registry.Visibility,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.CreatedAt,
&registry.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
}

View File

@ -0,0 +1,417 @@
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
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -27,6 +28,11 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.ID = uuid.New().String() user.ID = uuid.New().String()
} }
// 设置默认值
if user.IsActive {
user.IsActive = true
}
query := ` query := `
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
@ -62,13 +68,14 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
` `
user := &entity.User{} user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan( err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.ID,
&user.Username, &user.Username,
&user.PasswordHash, &user.PasswordHash,
&user.Email, &user.Email,
&user.Role, &user.Role,
&user.WorkspaceID, &workspaceID,
&user.IsActive, &user.IsActive,
&user.MustChangePassword, &user.MustChangePassword,
&user.RevokedAfter, &user.RevokedAfter,
@ -76,6 +83,13 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
&user.UpdatedAt, &user.UpdatedAt,
) )
// Handle NULL workspace_id
if workspaceID.Valid {
user.WorkspaceID = workspaceID.String
} else {
user.WorkspaceID = ""
}
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, entity.ErrUserNotFound return nil, entity.ErrUserNotFound
} }
@ -88,20 +102,24 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
// GetByUsername 根据用户名获取用户 // GetByUsername 根据用户名获取用户
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) { func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
log.Printf("[DEBUG] GetByUsername called with username: %q", username)
query := ` query := `
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users FROM users
WHERE username = $1 WHERE username = $1
` `
log.Printf("[DEBUG] Executing query: %s with param: %s", query, username)
user := &entity.User{} user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, username).Scan( err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
&user.ID, &user.ID,
&user.Username, &user.Username,
&user.PasswordHash, &user.PasswordHash,
&user.Email, &user.Email,
&user.Role, &user.Role,
&user.WorkspaceID, &workspaceID,
&user.IsActive, &user.IsActive,
&user.MustChangePassword, &user.MustChangePassword,
&user.RevokedAfter, &user.RevokedAfter,
@ -109,13 +127,25 @@ func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*e
&user.UpdatedAt, &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 { if err == sql.ErrNoRows {
log.Printf("[DEBUG] User not found in DB")
return nil, entity.ErrUserNotFound return nil, entity.ErrUserNotFound
} }
if err != nil { if err != nil {
log.Printf("[DEBUG] Scan error: %v", err)
return nil, fmt.Errorf("failed to get user: %w", err) return nil, fmt.Errorf("failed to get user: %w", err)
} }
log.Printf("[DEBUG] Found user: %+v", user)
return user, nil return user, nil
} }
@ -125,8 +155,7 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
query := ` query := `
UPDATE users UPDATE users
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5, 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
is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
WHERE id = $10 WHERE id = $10
` `
@ -222,3 +251,92 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
return users, nil 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
}

View File

@ -0,0 +1,287 @@
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
}

View File

@ -3,7 +3,6 @@ package postgres
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
@ -12,334 +11,187 @@ import (
"github.com/ocdp/cluster-service/internal/domain/repository" "github.com/ocdp/cluster-service/internal/domain/repository"
) )
// WorkspaceRepository PostgreSQL Workspace 仓储实现
type WorkspaceRepository struct { type WorkspaceRepository struct {
db *DB db *DB
} }
// NewWorkspaceRepository 创建 PostgreSQL Workspace 仓储
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository { func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
return &WorkspaceRepository{db: db} return &WorkspaceRepository{db: db}
} }
// Create 创建 Workspace
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error { func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
if workspace.ID == "" { if workspace.ID == "" {
workspace.ID = uuid.New().String() workspace.ID = uuid.New().String()
} }
query := ` query := `
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) INSERT INTO workspaces (id, name, description, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) VALUES ($1, $2, $3, $4, $5, $6)
` `
_, err := r.db.conn.ExecContext(ctx, query, _, err := r.db.conn.ExecContext(ctx, query,
workspace.ID, workspace.ID,
workspace.Name, workspace.Name,
workspace.Status, workspace.Description,
workspace.K8sNamespace,
workspace.K8sSAName,
workspace.DefaultClusterID,
workspace.QuotaCPU,
workspace.QuotaMemory,
workspace.QuotaGPU,
workspace.QuotaGPUMem,
workspace.CreatedBy, workspace.CreatedBy,
workspace.CreatedAt, workspace.CreatedAt,
workspace.UpdatedAt, workspace.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to create workspace: %w", err) return fmt.Errorf("failed to create workspace: %w", err)
} }
return nil return nil
} }
// GetByID 根据 ID 获取 Workspace
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) { func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
return r.get(ctx, "id = $1", id) query := `
} SELECT id, name, description, created_by, created_at, updated_at
func (r *WorkspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
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 FROM workspaces
WHERE %s WHERE id = $1
`, where) `
workspace := &entity.Workspace{} workspace := &entity.Workspace{}
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
err := r.db.conn.QueryRowContext(ctx, query, arg).Scan(
&workspace.ID, &workspace.ID,
&workspace.Name, &workspace.Name,
&workspace.Status, &workspace.Description,
&workspace.K8sNamespace, &workspace.CreatedBy,
&workspace.K8sSAName,
&defaultClusterID,
&quotaCPU,
&quotaMemory,
&quotaGPU,
&quotaGPUMem,
&createdBy,
&workspace.CreatedAt, &workspace.CreatedAt,
&workspace.UpdatedAt, &workspace.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound return nil, entity.ErrWorkspaceNotFound
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err) 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 return workspace, nil
} }
// 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
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, name).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
}
// Update 更新 Workspace
func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error { func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error {
workspace.UpdatedAt = time.Now() workspace.UpdatedAt = time.Now()
query := ` query := `
UPDATE workspaces UPDATE workspaces
SET name = $1, status = $2, k8s_namespace = $3, k8s_sa_name = $4, SET name = $1, description = $2, updated_at = $3
default_cluster_id = $5, WHERE id = $4
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, result, err := r.db.conn.ExecContext(ctx, query,
workspace.Name, workspace.Name,
workspace.Status, workspace.Description,
workspace.K8sNamespace,
workspace.K8sSAName,
workspace.DefaultClusterID,
workspace.QuotaCPU,
workspace.QuotaMemory,
workspace.QuotaGPU,
workspace.QuotaGPUMem,
workspace.CreatedBy,
workspace.UpdatedAt, workspace.UpdatedAt,
workspace.ID, workspace.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to update workspace: %w", err) return fmt.Errorf("failed to update workspace: %w", err)
} }
rows, err := result.RowsAffected() rows, err := result.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err) return fmt.Errorf("failed to get affected rows: %w", err)
} }
if rows == 0 { if rows == 0 {
return entity.ErrWorkspaceNotFound return entity.ErrWorkspaceNotFound
} }
return nil 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) { func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
query := ` query := `
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 SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces FROM workspaces
ORDER BY created_at DESC ORDER BY created_at DESC
` `
rows, err := r.db.conn.QueryContext(ctx, query) rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list workspaces: %w", err) return nil, fmt.Errorf("failed to list workspaces: %w", err)
} }
defer rows.Close() defer rows.Close()
workspaces := make([]*entity.Workspace, 0) workspaces := make([]*entity.Workspace, 0)
for rows.Next() { for rows.Next() {
workspace := &entity.Workspace{} workspace := &entity.Workspace{}
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString err := rows.Scan(
if err := rows.Scan(
&workspace.ID, &workspace.ID,
&workspace.Name, &workspace.Name,
&workspace.Status, &workspace.Description,
&workspace.K8sNamespace, &workspace.CreatedBy,
&workspace.K8sSAName,
&defaultClusterID,
&quotaCPU,
&quotaMemory,
&quotaGPU,
&quotaGPUMem,
&createdBy,
&workspace.CreatedAt, &workspace.CreatedAt,
&workspace.UpdatedAt, &workspace.UpdatedAt,
); err != nil { )
if err != nil {
return nil, fmt.Errorf("failed to scan workspace: %w", err) 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) workspaces = append(workspaces, workspace)
} }
return workspaces, rows.Err()
}
type WorkspaceClusterBindingRepository struct { if err := rows.Err(); err != nil {
db *DB return nil, fmt.Errorf("rows iteration error: %w", err)
} }
func NewWorkspaceClusterBindingRepository(db *DB) repository.WorkspaceClusterBindingRepository { return workspaces, nil
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
}
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()
} }

View File

@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
) )
@ -23,7 +21,6 @@ type UserSeed struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"`
} }
// RegistrySeed Registry 预注入数据 // RegistrySeed Registry 预注入数据
@ -52,9 +49,8 @@ type ClusterSeed struct {
// //
// 加载优先级: // 加载优先级:
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级) // 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
// 2. 环境变量 BOOTSTRAP_* (root .env / container env) // 2. Mock 模式: 配置文件 config/bootstrap.json
// 3. Mock 模式: 配置文件 config/bootstrap.json // 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取
// 4. 未提供任何 bootstrap 配置时禁用预注入
func LoadBootstrapConfig() (*BootstrapConfig, error) { func LoadBootstrapConfig() (*BootstrapConfig, error) {
// 1. 优先从环境变量加载 // 1. 优先从环境变量加载
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" { if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
@ -65,10 +61,6 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
return &config, nil return &config, nil
} }
if config, ok := loadBootstrapConfigFromEnv(); ok {
return config, nil
}
// 2. 检查适配器模式 // 2. 检查适配器模式
adapterMode := os.Getenv("ADAPTER_MODE") adapterMode := os.Getenv("ADAPTER_MODE")
@ -81,7 +73,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
// 检查文件是否存在 // 检查文件是否存在
if _, err := os.Stat(configPath); os.IsNotExist(err) { if _, err := os.Stat(configPath); os.IsNotExist(err) {
// 配置文件不存在,不预注入任何数据 // 配置文件不存在,使用默认配置
return GetDefaultBootstrapConfig(), nil return GetDefaultBootstrapConfig(), nil
} }
@ -98,142 +90,87 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
return &config, nil return &config, nil
} }
// 3. 真实模式: 未显式配置时不预注入任何数据 // 3. 真实模式 (mode 1, mode 2): 从 .env 读取
return GetDefaultBootstrapConfig(), nil return GetDefaultBootstrapConfig(), nil
} }
func loadBootstrapConfigFromEnv() (*BootstrapConfig, bool) { // GetDefaultBootstrapConfig 从 .env 加载 bootstrap 数据。
if !hasBootstrapEnv() { // 支持 BOOTSTRAP_CLUSTERS (逗号分隔的集群名) 以及每个集群的
return nil, false // BOOTSTRAP_CLUSTER_<NAME>_HOST, _CA, _CERT, _KEY, _DESC。
} // 支持 BOOTSTRAP_REGISTRY_* 环境变量。
// 支持 BOOTSTRAP_ADMIN_USER/PASS/EMAIL。
config := &BootstrapConfig{ func GetDefaultBootstrapConfig() *BootstrapConfig {
Enabled: true, // Load clusters from .env (comma-separated list of cluster names)
Users: make([]UserSeed, 0, 1), clusterStr := os.Getenv("BOOTSTRAP_CLUSTERS")
Registries: make([]RegistrySeed, 0, 1), var clusterSeeds []ClusterSeed
Clusters: make([]ClusterSeed, 0), if clusterStr != "" {
} clusterNames := strings.Split(clusterStr, ",")
for _, name := range clusterNames {
adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER")) name = strings.TrimSpace(name)
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS")) if name == "" {
if adminUser != "" && adminPass != "" {
config.Users = append(config.Users, UserSeed{
Username: adminUser,
Password: adminPass,
Email: getEnv("BOOTSTRAP_ADMIN_EMAIL", adminUser+"@example.local"),
Role: "admin",
})
}
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 continue
} }
key := sanitizeEnvKey(name)
config.Clusters = append(config.Clusters, ClusterSeed{ host := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_HOST")
Name: strings.ToLower(clusterName), ca := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CA")
Host: host, cert := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CERT")
Description: os.Getenv(prefix + "DESC"), keyData := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_KEY")
CAData: os.Getenv(prefix + "CA"), desc := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_DESC")
CertData: os.Getenv(prefix + "CERT"), if host != "" {
KeyData: os.Getenv(prefix + "KEY"), clusterSeeds = append(clusterSeeds, ClusterSeed{
Token: os.Getenv(prefix + "TOKEN"), Name: name,
}) Host: host,
} Description: desc,
} CAData: ca,
CertData: cert,
return config, true KeyData: keyData,
} })
func hasBootstrapEnv() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "BOOTSTRAP_") {
return true
}
}
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() { // Load registry from .env
key, _, ok := strings.Cut(env, "=") var registrySeeds []RegistrySeed
if !ok || !strings.HasPrefix(key, "BOOTSTRAP_CLUSTER_") || !strings.HasSuffix(key, "_HOST") { regName := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_NAME"))
continue regURL := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_URL"))
} if regName != "" && regURL != "" {
name := strings.TrimSuffix(strings.TrimPrefix(key, "BOOTSTRAP_CLUSTER_"), "_HOST") registrySeeds = append(registrySeeds, RegistrySeed{
if name != "" { Name: regName,
names[name] = struct{}{} 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",
})
} }
result := make([]string, 0, len(names)) // Load users from .env
for name := range names { var userSeeds []UserSeed
result = append(result, name) adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER"))
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS"))
if adminUser != "" {
userSeeds = append(userSeeds, UserSeed{
Username: adminUser,
Password: adminPass,
Email: strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_EMAIL")),
})
} }
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{ return &BootstrapConfig{
Enabled: false, Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0,
Users: []UserSeed{}, Users: userSeeds,
Registries: []RegistrySeed{}, Registries: registrySeeds,
Clusters: []ClusterSeed{}, Clusters: clusterSeeds,
} }
} }
// 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 '_'
}
return r
}, name)
return strings.ToUpper(s)
}

View File

@ -1,103 +0,0 @@
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))
}
}

View File

@ -84,12 +84,6 @@ func (s *Seeder) seedUsers(ctx context.Context) error {
// 创建用户 // 创建用户
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email) user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
user.ID = uuid.New().String() 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 { if err := s.repos.UserRepo.Create(ctx, user); err != nil {
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err) log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
@ -111,7 +105,6 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries)) log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
ownerID := s.bootstrapOwnerID(ctx)
for _, registrySeed := range s.config.Registries { for _, registrySeed := range s.config.Registries {
// 检查 Registry 是否已存在 // 检查 Registry 是否已存在
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name) existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
@ -124,9 +117,6 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
registry := &entity.Registry{ registry := &entity.Registry{
ID: uuid.New().String(), ID: uuid.New().String(),
Name: registrySeed.Name, Name: registrySeed.Name,
WorkspaceID: entity.DefaultWorkspaceID,
OwnerID: ownerID,
Visibility: "global_shared",
URL: registrySeed.URL, URL: registrySeed.URL,
Description: registrySeed.Description, Description: registrySeed.Description,
Username: registrySeed.Username, Username: registrySeed.Username,
@ -156,7 +146,6 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters)) log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
ownerID := s.bootstrapOwnerID(ctx)
for _, clusterSeed := range s.config.Clusters { for _, clusterSeed := range s.config.Clusters {
// 检查 Cluster 是否已存在 // 检查 Cluster 是否已存在
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name) existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
@ -169,9 +158,6 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
cluster := &entity.Cluster{ cluster := &entity.Cluster{
ID: uuid.New().String(), ID: uuid.New().String(),
Name: clusterSeed.Name, Name: clusterSeed.Name,
WorkspaceID: entity.DefaultWorkspaceID,
OwnerID: ownerID,
Visibility: "global_shared",
Host: clusterSeed.Host, Host: clusterSeed.Host,
Description: clusterSeed.Description, Description: clusterSeed.Description,
CAData: clusterSeed.CAData, CAData: clusterSeed.CAData,
@ -193,22 +179,3 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
return nil 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 ""
}

View File

@ -1,8 +1,8 @@
package entity package entity
import ( import (
"strings" "strings"
"time" "time"
) )
// ArtifactType Artifact 类型 // ArtifactType Artifact 类型
@ -16,16 +16,16 @@ const (
// Artifact OCI Artifact 领域实体 // Artifact OCI Artifact 领域实体
type Artifact struct { type Artifact struct {
RegistryID string RegistryID string
Repository string Repository string
Tag string Tag string
Digest string Digest string
Type ArtifactType Type ArtifactType
Size int64 Size int64
MediaType string MediaType string
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断) ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
Annotations map[string]string Annotations map[string]string
CreatedAt time.Time CreatedAt time.Time
} }
// Repository 仓库信息 // Repository 仓库信息
@ -50,34 +50,34 @@ func NewArtifact(registryID, repository, tag, digest string) *Artifact {
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other // SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断 // 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
func (a *Artifact) SetType(mediaType string) { 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 { containsAny := func(target string, keywords ...string) bool {
for _, keyword := range keywords { for _, keyword := range keywords {
if keyword != "" && strings.Contains(target, keyword) { if keyword != "" && strings.Contains(target, keyword) {
return true return true
} }
} }
return false return false
} }
switch { switch {
case lowerMediaType == "": case lowerMediaType == "":
a.Type = ArtifactTypeOther a.Type = ArtifactTypeOther
case containsAny(lowerMediaType, case containsAny(lowerMediaType,
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config", "helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
): ):
a.Type = ArtifactTypeChart a.Type = ArtifactTypeChart
case containsAny(lowerMediaType, case containsAny(lowerMediaType,
"docker", "vnd.docker", "docker.distribution", "docker.container.image", "docker", "vnd.docker", "docker.distribution", "docker.container.image",
"vnd.oci", "oci.image", "opencontainers", "container.image", "vnd.oci", "oci.image", "opencontainers", "container.image",
): ):
a.Type = ArtifactTypeImage a.Type = ArtifactTypeImage
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"): case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
a.Type = ArtifactTypeImage a.Type = ArtifactTypeImage
default: default:
a.Type = ArtifactTypeOther a.Type = ArtifactTypeOther
} }
} }
// DetermineType 智能判断 Artifact 类型(综合多种信息) // DetermineType 智能判断 Artifact 类型(综合多种信息)
@ -87,84 +87,85 @@ func (a *Artifact) SetType(mediaType string) {
// 3. Repository 名称 - charts/ 前缀暗示 // 3. Repository 名称 - charts/ 前缀暗示
// 4. MediaType - 兜底判断 // 4. MediaType - 兜底判断
func (a *Artifact) DetermineType() { func (a *Artifact) DetermineType() {
containsAny := func(target string, keywords ...string) bool { containsAny := func(target string, keywords ...string) bool {
for _, keyword := range keywords { for _, keyword := range keywords {
if keyword != "" && strings.Contains(target, keyword) { if keyword != "" && strings.Contains(target, keyword) {
return true return true
} }
} }
return false return false
} }
// 1. 优先检查 ConfigType最准确的判断方式 // 1. 优先检查 ConfigType最准确的判断方式
if a.ConfigType != "" { if a.ConfigType != "" {
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType)) lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
// Helm Chart 的 config.mediaType // Helm Chart 的 config.mediaType
if containsAny(lowerConfigType, if containsAny(lowerConfigType,
"helm.config", "cncf.helm", "helm.chart", "chart.content", "helm.config", "cncf.helm", "helm.chart", "chart.content",
) { ) {
a.Type = ArtifactTypeChart a.Type = ArtifactTypeChart
return return
} }
// Docker/OCI Image 的 config.mediaType // Docker/OCI Image 的 config.mediaType
if containsAny(lowerConfigType, if containsAny(lowerConfigType,
"docker.container.image", "oci.image.config", "docker.container.image", "oci.image.config",
) { ) {
a.Type = ArtifactTypeImage a.Type = ArtifactTypeImage
return return
} }
} }
// 2. 检查 Annotations // 2. 检查 Annotations
for key, value := range a.Annotations { for key, value := range a.Annotations {
lowerKey := strings.ToLower(key) lowerKey := strings.ToLower(key)
lowerValue := strings.ToLower(value) lowerValue := strings.ToLower(value)
if containsAny(lowerKey, "helm", "chart") || if containsAny(lowerKey, "helm", "chart") ||
containsAny(lowerValue, "helm", "chart") { containsAny(lowerValue, "helm", "chart") {
a.Type = ArtifactTypeChart a.Type = ArtifactTypeChart
return return
} }
} }
// 3. 检查 Repository 名称(辅助判断) // 3. 检查 Repository 名称(辅助判断)
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") { if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
// charts/ 开头的仓库很可能是 Helm Chart // charts/ 开头的仓库很可能是 Helm Chart
// 但需要结合 MediaType 进一步确认 // 但需要结合 MediaType 进一步确认
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType)) lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart // 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
if strings.Contains(lowerMediaType, "oci.image.manifest") || if strings.Contains(lowerMediaType, "oci.image.manifest") ||
strings.Contains(lowerMediaType, "vnd.oci") { strings.Contains(lowerMediaType, "vnd.oci") {
a.Type = ArtifactTypeChart a.Type = ArtifactTypeChart
return return
} }
} }
// 4. 回退到基于 MediaType 的判断(兜底逻辑) // 4. 回退到基于 MediaType 的判断(兜底逻辑)
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType)) lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
switch { switch {
case lowerMediaType == "": case lowerMediaType == "":
a.Type = ArtifactTypeOther a.Type = ArtifactTypeOther
case containsAny(lowerMediaType, case containsAny(lowerMediaType,
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config", "helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
): ):
a.Type = ArtifactTypeChart a.Type = ArtifactTypeChart
case containsAny(lowerMediaType, case containsAny(lowerMediaType,
"docker", "vnd.docker", "docker.distribution", "docker.container.image", "docker", "vnd.docker", "docker.distribution", "docker.container.image",
): ):
a.Type = ArtifactTypeImage a.Type = ArtifactTypeImage
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"): case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
a.Type = ArtifactTypeImage a.Type = ArtifactTypeImage
default: default:
a.Type = ArtifactTypeOther a.Type = ArtifactTypeOther
} }
} }
// IsChart 判断是否为 Helm Chart // IsChart 判断是否为 Helm Chart
func (a *Artifact) IsChart() bool { func (a *Artifact) IsChart() bool {
return a.Type == ArtifactTypeChart return a.Type == ArtifactTypeChart
} }

View File

@ -0,0 +1,90 @@
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
}

View File

@ -0,0 +1,41 @@
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
}

View File

@ -4,12 +4,19 @@ import (
"time" "time"
) )
// IsolationMode 集群隔离模式
type IsolationMode string
const (
IsolationModeNamespace IsolationMode = "namespace" // 共享集群模式,多 workspace 使用不同 namespace
IsolationModeCluster IsolationMode = "cluster" // 私有集群模式,每个 workspace 独立集群
)
// Cluster Kubernetes 集群领域实体 // Cluster Kubernetes 集群领域实体
type Cluster struct { type Cluster struct {
ID string ID string
WorkspaceID string WorkspaceID string // 所属 workspaceNULL 表示全局共享
OwnerID string OwnerID string // 创建者用户 ID
Visibility string
Name string Name string
Host string // Kubernetes API Server URL Host string // Kubernetes API Server URL
CAData string // Base64 encoded CA certificate CAData string // Base64 encoded CA certificate
@ -17,20 +24,29 @@ type Cluster struct {
KeyData string // Base64 encoded client key KeyData string // Base64 encoded client key
Token string // Bearer token (alternative to cert auth) Token string // Bearer token (alternative to cert auth)
Description string Description string
DefaultNamespace string
CreatedAt time.Time // 隔离模式
UpdatedAt time.Time IsolationMode IsolationMode // 'namespace' | 'cluster'
DefaultNamespace string // 当 isolation_mode=namespace 时的默认 namespace 前缀
IsShared bool // 是否为共享集群admin 创建供多 workspace 使用)
CreatedAt time.Time
UpdatedAt time.Time
} }
// NewCluster 创建新集群 // NewCluster 创建新集群
func NewCluster(name, host string) *Cluster { func NewCluster(workspaceID, ownerID, name, host string) *Cluster {
now := time.Now() now := time.Now()
return &Cluster{ return &Cluster{
Name: name, WorkspaceID: workspaceID,
Host: host, OwnerID: ownerID,
Visibility: "private", Name: name,
CreatedAt: now, Host: host,
UpdatedAt: now, IsolationMode: IsolationModeNamespace, // 默认 namespace 隔离模式
DefaultNamespace: workspaceID, // 默认使用 workspace ID 作为 namespace 前缀
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
} }
} }
@ -68,14 +84,35 @@ func (c *Cluster) Validate() error {
if c.Host == "" { if c.Host == "" {
return ErrInvalidClusterHost return ErrInvalidClusterHost
} }
if c.Visibility == "" {
c.Visibility = "private" // 检查是否有 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
} }
// 必须有认证方式:证书或 Token
if (c.CertData == "" || c.KeyData == "") && c.Token == "" { return ErrInvalidClusterAuth
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
} }
return nil // namespace 隔离模式
if c.DefaultNamespace != "" {
return c.DefaultNamespace + "-" + instanceName
}
return workspaceName + "-" + instanceName
} }
// GetKubeConfig 生成 kubeconfig 内容 // GetKubeConfig 生成 kubeconfig 内容
@ -108,3 +145,4 @@ users:
return kubeconfig return kubeconfig
} }

View File

@ -5,15 +5,11 @@ import "errors"
// 领域错误定义 // 领域错误定义
var ( var (
// User errors // User errors
ErrInvalidUsername = errors.New("invalid username") ErrInvalidUsername = errors.New("invalid username")
ErrInvalidPassword = errors.New("invalid password") ErrInvalidPassword = errors.New("invalid password")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists") ErrUserExists = errors.New("user already exists")
ErrTokenRevoked = errors.New("token has been revoked") 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 // Cluster errors
ErrInvalidClusterName = errors.New("invalid cluster name") ErrInvalidClusterName = errors.New("invalid cluster name")
@ -41,8 +37,32 @@ var (
ErrArtifactNotFound = errors.New("artifact not found") ErrArtifactNotFound = errors.New("artifact not found")
ErrRepositoryNotFound = errors.New("repository not found") ErrRepositoryNotFound = errors.New("repository not found")
ErrValuesSchemaNotFound = errors.New("values schema not found") ErrValuesSchemaNotFound = errors.New("values schema not found")
ErrValuesNotFound = errors.New("values not found")
// Workspace errors // Workspace errors
ErrWorkspaceNotFound = errors.New("workspace not found") ErrInvalidWorkspaceName = errors.New("invalid workspace name")
ErrWorkspaceExists = errors.New("workspace already exists") 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")
) )

View File

@ -1,7 +1,12 @@
package entity package entity
import ( import (
"log"
"strings"
"time" "time"
"unicode"
"gopkg.in/yaml.v3"
) )
// InstanceStatus 实例状态 // InstanceStatus 实例状态
@ -33,46 +38,65 @@ const (
// Instance Helm 应用实例领域实体 // Instance Helm 应用实例领域实体
type Instance struct { type Instance struct {
ID string ID string
WorkspaceID string WorkspaceID string // 所属 workspace
OwnerID string OwnerID string // 创建者用户 ID
ClusterID string ClusterID string
Name string // Helm Release Name RegistryID string
Namespace string ChartReferenceID string // 引用的 Chart 引用
RegistryID string ValuesTemplateID string // 使用的 Values 模板
Repository string // OCI Repository (e.g., charts/app)
Chart string // Chart Name Name string // Helm Release Name
Version string // Chart Version Namespace string
Description string Repository string // OCI Repository (e.g., charts/app)
Values map[string]interface{} // Helm Values (JSON) Chart string // Chart Name
ValuesYAML string // Helm Values (YAML format) Version string // Chart Version
Status InstanceStatus Description string
StatusReason string Values map[string]interface{} // Helm Values (JSON)
LastOperation InstanceOperation ValuesYAML string // Helm Values (YAML format)
LastError string UserOverrideYAML string // 用户额外覆盖的配置
Revision int // Helm Release Revision
CreatedAt time.Time Status InstanceStatus
UpdatedAt time.Time StatusReason string
Replicas int // Running K8s replicas (enriched, not persisted) 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
} }
// NewInstance 创建新实例 // NewInstance 创建新实例
func NewInstance(clusterID, name, namespace, registryID, repository, chart, version string) *Instance { func NewInstance(workspaceID, ownerID, clusterID, registryID, chartReferenceID, valuesTemplateID, name, namespace, repository, chart, version string) *Instance {
now := time.Now() now := time.Now()
return &Instance{ return &Instance{
ClusterID: clusterID, WorkspaceID: workspaceID,
Name: name, OwnerID: ownerID,
Namespace: namespace, ClusterID: clusterID,
RegistryID: registryID, RegistryID: registryID,
Repository: repository, ChartReferenceID: chartReferenceID,
Chart: chart, ValuesTemplateID: valuesTemplateID,
Version: version, Name: name,
Status: StatusPending, Namespace: namespace,
StatusReason: "Pending install", Repository: repository,
LastOperation: OperationInstall, Chart: chart,
Revision: 1, Version: version,
CreatedAt: now, Status: StatusPending,
UpdatedAt: now, StatusReason: "Pending install",
LastOperation: OperationInstall,
Revision: 1,
CPURequested: 0,
MemoryRequested: "0Mi",
GPURequested: 0,
GPUMemoryRequested: "0Mi",
CreatedAt: now,
UpdatedAt: now,
} }
} }
@ -82,9 +106,31 @@ func (i *Instance) SetValues(values map[string]interface{}) {
i.UpdatedAt = time.Now() i.UpdatedAt = time.Now()
} }
// SetValuesYAML 设置 YAML 格式的 Values // SetValuesYAML 设置 YAML 格式的 Values 并解析到 Values map
func (i *Instance) SetValuesYAML(yaml string) { func (i *Instance) SetValuesYAML(yamlStr string) {
i.ValuesYAML = yaml 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
}
}
i.UpdatedAt = time.Now() i.UpdatedAt = time.Now()
} }
@ -157,13 +203,43 @@ func (i *Instance) Upgrade(version string, values map[string]interface{}) {
i.BeginOperation(OperationUpgrade, "Pending upgrade") 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 验证实例配置 // Validate 验证实例配置
func (i *Instance) Validate() error { func (i *Instance) Validate() error {
if i.ClusterID == "" { if i.ClusterID == "" {
return ErrInvalidClusterID return ErrInvalidClusterID
} }
if i.Name == "" { if err := ValidateReleaseName(i.Name); err != nil {
return ErrInvalidInstanceName return err
} }
if i.Namespace == "" { if i.Namespace == "" {
return ErrInvalidNamespace return ErrInvalidNamespace

View File

@ -1,70 +0,0 @@
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
}

View File

@ -4,34 +4,34 @@ import "time"
// ClusterMetrics 集群监控指标 // ClusterMetrics 集群监控指标
type ClusterMetrics struct { type ClusterMetrics struct {
ClusterID string `json:"cluster_id"` ClusterID string `json:"cluster_id"`
ClusterName string `json:"cluster_name"` ClusterName string `json:"cluster_name"`
Status string `json:"status"` // healthy, warning, error, unknown Status string `json:"status"` // healthy, warning, error, unknown
Uptime string `json:"uptime"` Uptime string `json:"uptime"`
NodeCount int `json:"node_count"` NodeCount int `json:"node_count"`
PodCount int `json:"pod_count"` PodCount int `json:"pod_count"`
LastCheck time.Time `json:"last_check"` LastCheck time.Time `json:"last_check"`
// 集群级别资源汇总 // 集群级别资源汇总
TotalCPU string `json:"total_cpu"` // 如 "8 cores" TotalCPU string `json:"total_cpu"` // 如 "8 cores"
TotalMemory string `json:"total_memory"` // 如 "32 GB" TotalMemory string `json:"total_memory"` // 如 "32 GB"
TotalGPU int `json:"total_gpu"` // GPU 总数 TotalGPU int `json:"total_gpu"` // GPU 总数
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores" UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
UsedMemory string `json:"used_memory"` // 如 "16 GB" UsedMemory string `json:"used_memory"` // 如 "16 GB"
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数 UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
CPUUsage float64 `json:"cpu_usage"` // 百分比 CPUUsage float64 `json:"cpu_usage"` // 百分比
MemoryUsage float64 `json:"memory_usage"` // 百分比 MemoryUsage float64 `json:"memory_usage"` // 百分比
GPUUsage float64 `json:"gpu_usage"` // 百分比 GPUUsage float64 `json:"gpu_usage"` // 百分比
// 单机资源最大值 // 单机资源最大值
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量如 "8 cores" MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量如 "8 cores"
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB" MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量 MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率 MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率 MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率 MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
// 节点列表(简化信息) // 节点列表(简化信息)
Nodes []NodeMetrics `json:"nodes,omitempty"` Nodes []NodeMetrics `json:"nodes,omitempty"`
@ -39,20 +39,20 @@ type ClusterMetrics struct {
// NodeMetrics 节点监控指标 // NodeMetrics 节点监控指标
type NodeMetrics struct { type NodeMetrics struct {
NodeName string `json:"node_name"` NodeName string `json:"node_name"`
Status string `json:"status"` // Ready, NotReady Status string `json:"status"` // Ready, NotReady
Role string `json:"role"` // control-plane, worker Role string `json:"role"` // control-plane, worker
Age string `json:"age"` Age string `json:"age"`
PodCount int `json:"pod_count"` PodCount int `json:"pod_count"`
// CPU 资源 // CPU 资源
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores" CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
CPUAllocatable string `json:"cpu_allocatable"` CPUAllocatable string `json:"cpu_allocatable"`
CPUUsage string `json:"cpu_usage"` CPUUsage string `json:"cpu_usage"`
CPUPercent float64 `json:"cpu_percent"` CPUPercent float64 `json:"cpu_percent"`
// 内存资源 // 内存资源
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB" MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
MemoryAllocatable string `json:"memory_allocatable"` MemoryAllocatable string `json:"memory_allocatable"`
MemoryUsage string `json:"memory_usage"` MemoryUsage string `json:"memory_usage"`
MemoryPercent float64 `json:"memory_percent"` MemoryPercent float64 `json:"memory_percent"`
@ -64,10 +64,10 @@ type NodeMetrics struct {
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4" GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
// 其他信息 // 其他信息
OSImage string `json:"os_image,omitempty"` OSImage string `json:"os_image,omitempty"`
KernelVersion string `json:"kernel_version,omitempty"` KernelVersion string `json:"kernel_version,omitempty"`
ContainerRuntime string `json:"container_runtime,omitempty"` ContainerRuntime string `json:"container_runtime,omitempty"`
KubeletVersion string `json:"kubelet_version,omitempty"` KubeletVersion string `json:"kubelet_version,omitempty"`
} }
// MonitoringSummary 监控汇总 // MonitoringSummary 监控汇总
@ -80,3 +80,4 @@ type MonitoringSummary struct {
TotalPods int `json:"total_pods"` TotalPods int `json:"total_pods"`
LastUpdate time.Time `json:"last_update"` LastUpdate time.Time `json:"last_update"`
} }

View File

@ -0,0 +1,79 @@
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
}

View File

@ -7,28 +7,30 @@ import (
// Registry OCI Registry 领域实体 // Registry OCI Registry 领域实体
type Registry struct { type Registry struct {
ID string ID string
WorkspaceID string WorkspaceID string // 所属 workspaceNULL 表示全局共享
OwnerID string OwnerID string // 创建者用户 ID
Visibility string
Name string Name string
URL string URL string
Description string Description string
Username string Username string
Password string Password string
Insecure bool // 是否跳过 TLS 验证 Insecure bool // 是否跳过 TLS 验证
IsShared bool // 是否为共享 Registryadmin 创建供多 workspace 使用)
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// NewRegistry 创建新 Registry // NewRegistry 创建新 Registry
func NewRegistry(name, url string) *Registry { func NewRegistry(workspaceID, ownerID, name, url string) *Registry {
now := time.Now() now := time.Now()
return &Registry{ return &Registry{
Name: name, WorkspaceID: workspaceID,
URL: url, OwnerID: ownerID,
Visibility: "private", Name: name,
CreatedAt: now, URL: url,
UpdatedAt: now, IsShared: false,
CreatedAt: now,
UpdatedAt: now,
} }
} }
@ -59,8 +61,6 @@ func (r *Registry) Validate() error {
if r.URL == "" { if r.URL == "" {
return ErrInvalidRegistryURL return ErrInvalidRegistryURL
} }
if r.Visibility == "" {
r.Visibility = "private"
}
return nil return nil
} }

Some files were not shown because too many files have changed in this diff Show More