5 Commits
v1.2.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
296 changed files with 28004 additions and 36131 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`

17
.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/
@ -61,3 +64,17 @@ tmp/
temp/ temp/
*.tmp *.tmp
# Next.js stale build caches
frontend/.next.stale*/
# Debug/temp scripts
debug_*.py
test_*.py
# Kubeconfig (contains sensitive credentials)
*.kubeconfig
kubeconfig
# AI model output / context storage
.claude/

46
CLAUDE.md Normal file
View File

@ -0,0 +1,46 @@
# Project Overview
# 🤖 Claude Code Agentic Workflow (Strictly Follow)
作为本项目的资深 AI 研发工程师,你在执行任何指令时,必须严格遵守以下核心原则与工作流。
## . 核心原则 (Core Principles)
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky在掌握全局上下文后用优雅的方式重构它但不要过度设计
## Ⅱ. 任务管理闭环 (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

@ -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>

218
Makefile
View File

@ -1,56 +1,192 @@
# ============================================================ # ============================================================
# OCDP stack orchestration Makefile # OCDP - Open Cloud Development Platform
# run-2: 构建前端静态资源 + 启动 nginx统一入口和 backend 栈 # Makefile for Docker Compose deployment
# clean-2: 清理 run-2 产生的容器 / 卷 / 网络
# ============================================================ # ============================================================
SHELL := /bin/bash SHELL := /bin/bash
COMPOSE_BIN ?= docker compose # ============================================================
# Configuration - Modify these for your environment
# ============================================================
ROOT_COMPOSE := docker-compose.yml # Server IP for external access (客户端访问IP)
BACKEND_COMPOSE := backend/docker-compose.yml SERVER_IP ?= 10.6.80.114
BACKEND_PROFILE := backend
COMPOSE_STACK := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE) --profile $(BACKEND_PROFILE) # Backend configuration
COMPOSE_STACK_ALL := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE) BACKEND_PORT ?= 8080
STACK_ENV := ADAPTER_MODE=production BACKEND_BUILD_CONTEXT=$(abspath backend) BACKEND_BUILD_DOCKERFILE=$(abspath backend/Dockerfile) BACKEND_MOCK_BUILD_DOCKERFILE=$(abspath backend/Dockerfile.mock) INIT_DB_SQL_PATH=$(abspath backend/scripts/init-db.sql) 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
STACK_SERVICES := postgres backend nginx # Allowed CORS origins (for external access)
# 格式: http://IP:端口,多个用逗号分隔
ALLOWED_ORIGINS ?= http://$(SERVER_IP),http://$(SERVER_IP):3000
.PHONY: run-2 clean-2 build-backend # Compose files
COMPOSE_FILES := -f docker-compose.yml -f backend/docker-compose.yml
run-2: # Database init SQL path (relative to project root)
@echo "═══════════════════════════════════════════════" INIT_DB_SQL_PATH ?= ./backend/scripts/init-db.sql
@echo "🚀 run-2: rebuild static assets + start web gateway stack"
@echo "═══════════════════════════════════════════════" # ============================================================
# 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 ""
@export COMPOSE_PROJECT_NAME=ocdp && \ @echo "✅ Services started:"
export ADAPTER_MODE=production && \ @echo " Frontend: http://$(SERVER_IP)"
export BACKEND_BUILD_CONTEXT=$(abspath backend) && \ @echo " Backend: http://$(SERVER_IP):$(BACKEND_PORT)/api/v1"
export BACKEND_BUILD_DOCKERFILE=$(abspath backend/Dockerfile) && \ @echo " Swagger: http://$(SERVER_IP):$(BACKEND_PORT)/api/docs"
export BACKEND_MOCK_BUILD_DOCKERFILE=$(abspath backend/Dockerfile.mock) && \ @echo " PostgreSQL: localhost:5432"
export INIT_DB_SQL_PATH=$(abspath backend/scripts/init-db.sql) && \
echo "→ Rebuilding frontend static assets" && \
$(COMPOSE_STACK) run --rm frontend-build && \
echo "" && \
echo "→ Rebuilding backend image" && \
$(COMPOSE_STACK) build backend && \
echo "" && \
echo "→ Bringing up backend + nginx services" && \
$(COMPOSE_STACK) up -d $(STACK_SERVICES)
@echo "" @echo ""
@echo "✅ Services online:" @echo " Default login: admin / admin123"
@echo "═══════════════════════════════════════════════" @echo "============================================"
clean-2: # Stop all services (保留数据)
@echo "═══════════════════════════════════════════════" down:
@echo "🧹 clean-2: tearing down run-2 stack" @echo "Stopping OCDP services..."
@echo "═══════════════════════════════════════════════" @docker compose $(COMPOSE_FILES) down
@$(COMPOSE_STACK_ALL) down --remove-orphans || true
@$(COMPOSE_STACK_ALL) down -v --remove-orphans || true
@$(COMPOSE_BIN) -f $(BACKEND_COMPOSE) down -v --remove-orphans || true
@echo "✅ Environment cleaned"
@echo "═══════════════════════════════════════════════"
# 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:
@echo "OCDP - Open Cloud Deployment Platform"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Main Commands:"
@echo " make up - 启动所有服务"
@echo " make down - 停止所有服务(保留数据)"
@echo " make restart - 重启所有服务"
@echo " make clean - 完全清理(删除所有数据)"
@echo " make rebuild - 强制重建并启动"
@echo ""
@echo "Build Commands:"
@echo " make build - 构建所有镜像"
@echo " make build-frontend - 只构建前端"
@echo " make build-backend - 只构建后端"
@echo ""
@echo "Log Commands:"
@echo " make logs - 查看所有日志"
@echo " make logs-backend - 只看后端日志"
@echo " make logs-frontend - 只看前端日志"
@echo " make logs-nginx - 只看nginx日志"
@echo ""
@echo "Database Commands:"
@echo " make db-reset - 重置数据库"
@echo " make db-init - 初始化数据库"
@echo " make db-shell - 进入数据库终端"
@echo ""
@echo "Utility Commands:"
@echo " make status - 查看服务状态"
@echo ""
@echo "Environment Variables:"
@echo " SERVER_IP=$(SERVER_IP) - 服务器IP默认: 10.6.80.114"
@echo " BACKEND_PORT=$(BACKEND_PORT) - 后端端口(默认: 8080"
@echo " ALLOWED_ORIGINS=$(ALLOWED_ORIGINS) - 允许的跨域来源"
@echo ""
@echo "Examples:"
@echo " make up SERVER_IP=192.168.1.100 # 自定义IP启动"
@echo " make clean # 完全清理并重新开始"
@echo "============================================"

127
Multi-Tenant Kubeconfig.md Normal file
View File

@ -0,0 +1,127 @@
# Technical Specification: Multi-Tenant Kubeconfig & Auth Gateway
## 1. System Overview & Goals
- **Objective**: Develop a backend API service that automates Kubernetes multi-tenant onboarding (Namespace + Quota isolation) and securely distributes short-lived, dynamic `kubeconfig` files using the Kubernetes `TokenRequest` API.
- **Architecture Independence**: This backend service acts as a standalone control plane. It is **not** strictly bound to a BFF pattern and does **not** need to run inside the target Kubernetes cluster (it supports Out-of-Cluster execution).
- **Out of Scope**: This spec does NOT cover the frontend UI implementation or the downstream workload deployment. It focuses strictly on identity, tenant provisioning, and credential brokering.
- **Security Principles**: Adhere strictly to Zero-Knowledge architecture (no token storage in DB), Ephemeral Credentials (short-lived tokens only), and Least Privilege (the Gateway must NOT be a `cluster-admin`).
## 2. Architecture & Topology
- **Tech Stack**: Go `net/http` (or FastAPI), utilizing the official Kubernetes Client SDK (`client-go` or `kubernetes-client/python`).
- **Control Plane Flow**:
1. Client/Frontend -> Gateway: User requests environment access.
2. Gateway -> K8s API: Gateway authenticates to the target K8s cluster using its own master credentials (e.g., an Out-of-Cluster `kubeconfig`).
3. Gateway -> K8s API: Executes Namespace/SA creation (if new) or calls `TokenRequest` API (if existing).
4. Gateway -> Client/Frontend: Returns a generated `kubeconfig` YAML string with the short-lived JWT token.
## 3. Core Business Logic Workflows
### Phase 1: Tenant Initialization (Onboarding)
Triggered when a new user registers or requests a workspace for the first time. The Gateway must execute a K8s transaction creating four resources:
1. **Namespace**: `tenant-{user_uuid}`
2. **ServiceAccount**: `sa-tenant-admin` (Created inside the tenant's namespace).
3. **RoleBinding**: Bind `sa-tenant-admin` to the `admin` (or custom) ClusterRole, strictly isolated within `tenant-{user_uuid}`.
4. **ResourceQuota**: Enforce limits (e.g., `requests.cpu: "4"`, `limits.memory: "16Gi"`) to prevent noisy neighbors.
### Phase 2: Credential Distribution (Dynamic Token)
Triggered when the user requests CLI access or downloads a kubeconfig.
1. Locate the user's associated Namespace and ServiceAccount, verifying the user's ownership of the workspace.
2. Audit Logging: Record the credential issuance event (User, IP, Workspace) into the database.
3. Call the `authentication.k8s.io/v1 TokenRequest` API targeting `sa-tenant-admin` in the specific tenant's namespace.
4. Set `expirationSeconds: 7200` (2 hours). Hard limit; cannot be extended.
5. Retrieve the generated JWT token and inject it into a pre-defined `kubeconfig` text template.
### Phase 3: Automated Renewal & Emergency Suspension
- **Session Management**: If accessed via a Web UI, the Gateway intercepts requests, attaches the dynamic token, and forwards them. If the token is within 10 minutes of expiration, the Gateway automatically issues a new TokenRequest.
- **Emergency Suspension**: If a workspace is marked compromised, the Gateway deletes its K8s `RoleBinding`, instantly revoking access for all currently active tokens of that tenant.
## 4. API Contracts
### 4.1. Initialize Tenant Workspace
- **Route**: `POST /api/v1/workspaces/init`
- **Auth**: Gateway Session / Bearer Token
- **Rate Limit**: Strictly rate-limited per user to prevent Namespace exhaustion.
- **Request Payload**:
```json
{
"tier": "basic" // Determines the ResourceQuota template
}
- **Response Payload (201 Created)**:
```json
{
"namespace": "tenant-a1b2c3d4",
"status": "provisioned",
"quota": {"cpu": "4", "memory": "8Gi"}
}
```
### 4.2. Generate Dynamic Kubeconfig
- **Route**: `GET /api/v1/workspaces/credentials/kubeconfig`
- **Auth**: Gateway Session / Bearer Token
- **Request Payload(200 OK)**: Returns raw `application/x-yaml`content.
```yaml
apiVersion: v1
clusters:
- cluster:
server: https://<k8s-api-server>
certificate-authority-data: <ca-base64>
name: internal-cluster
contexts:
- context:
cluster: internal-cluster
namespace: tenant-a1b2c3d4 # Default context locked to their namespace
user: sa-tenant-admin
name: tenant-context
current-context: tenant-context
kind: Config
users:
- name: sa-tenant-admin
user:
token: "eyJhbGciOiJSUzI1NiIs..." # Short-lived token injected here
```
### 4.3. Suspend Workspace (Emergency Kill Switch)
- **Route**: POST /api/v1/workspaces/{id}/suspend
- **Auth**: Admin Only
- **Behavior**: Updates DB status to suspended and deletes the associated K8s RoleBinding.
### 5. Data Architecture & Persistence
- **Database**: PostgreSQL (Relational mapping between Users and K8s Namespaces).
- **Table**: `users`
- `id` (UUID, PK),`email`,`password_hash`,`status`
- **Table**: `workspaces`
- `id` (UUID, PK)
- `user_id` (UUID, FK to Users table)
- `k8s_namespace` (String, unique)
- `k8s_sa_name` (String)
- `tier` (String)
- `created_at` (Timestamp)
- **Table**: `audit_logs`(Security Compliance)
- `id` (UUID, PK), `user_id` (UUID), `workspace_id` (UUID), `action` (e.g., IssueKubeconfig), `ip_address`, `created_at`
- **Constraint**: We do NOT store the K8s Token in the database. Tokens are ephemeral and generated on-the-fly.
## 6. Security, Threat Mitigation & Infrastructure Constraints
### 6.1 Threat Model
| Threat | Mitigation Strategy |
| :--- | :--- |
| **Gateway Compromise** | The Gateway uses a strictly restricted K8s role. It cannot read existing `Secrets` or interfere with other tenants' running Pods. |
| **Token Theft (XSS)** | Application-level Auth must use `HttpOnly, Secure` Cookies. Generated Kubeconfigs expire in 2 hours. |
| **Resource Abuse (Mining)** | Hardcoded `ResourceQuota` per tenant upon creation. Global `LimitRange` enforced at the cluster level. |
### 6.2 Restricted Gateway Credentials (Crucial)
The Gateway requires a K8s credential (Out-of-Cluster `kubeconfig` or Cloud IAM Role) to operate. **This credential MAY NOT have `cluster-admin` privileges.** It should be bound to a custom `ClusterRole` with ONLY the following permissions:
- `create`, `get`, `list` on `namespaces`, `resourcequotas`.
- `create`, `get`, `list` on `serviceaccounts`, `rolebindings`.
- `create` on `serviceaccounts/token` (CRITICAL for TokenRequest API).
- *Strictly prohibited*: `get` or `list` on `secrets`, `pods`, or `deployments`.
### 6.3 Deployment & Networking
- **Deployment Agnostic**: The application will be packaged as a Docker image and can be deployed via Docker Compose, standalone VMs, or within a Kubernetes cluster.
- **CORS/CSP**: Since this might not be a single-origin BFF, explicit CORS policies (`Access-Control-Allow-Origin`) must be tightly defined if the frontend is hosted on a separate domain. Wildcards (`*`) are prohibited.

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! 🚀

457
README.md
View File

@ -1,336 +1,201 @@
# OCDP - Open Cloud Development Platform # OCDP - Open Cloud Deployment Platform
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 开源云原生部署平台,支持从 Harbor或其他 OCI Registry拉取 Helm Charts 并一键部署到多个 Kubernetes 集群。
[![Go Version](https://img.shields.io/badge/go-1.24+-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/node-20+-339933?logo=node.js)](https://nodejs.org/)
[![Docker](https://img.shields.io/badge/docker-20.10+-2496ED?logo=docker)](https://www.docker.com/)
开源云原生开发平台,用于管理 Kubernetes 集群、OCI Registry 和 Helm Charts 部署。 ## 功能特性
--- - **多集群管理** - 支持多个 kubeconfig管理多个 K8S 集群
- **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
- **多租户支持** - Workspace 隔离,管理员和普通用户角色
- **存储后端** - NFS/PV/hostPath 存储配置管理
- **Chart 引用** - 管理可用的 Helm Charts
- **Values 模板** - 版本控制、支持回滚的配置模板
- **一键部署** - 从 Harbor 拉取 Charts 部署到指定集群
- **实例管理** - 支持升级、回滚、卸载 Helm Release
- **状态监控** - 实时同步 Helm Release 状态
## ✨ 特性 ## 技术栈
- 🎯 **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库 | 层级 | 技术 |
- 📦 **Artifact 浏览** - 浏览和管理 Helm Charts、容器镜像 |------|------|
- 🚀 **一键部署** - 可视化部署 Helm Charts 到 Kubernetes 集群 | 后端 | Go 1.21+, Hexagonal Architecture |
- 🔍 **智能过滤** - 按 MediaType 过滤 artifactschart、image、other | 前端 | React 18, TypeScript, Next.js, TailwindCSS |
- 🎨 **现代 UI** - 响应式设计,基于 React + TypeScript | 数据库 | PostgreSQL |
- 🔐 **安全认证** - JWT 认证,加密存储敏感信息 | 网关 | Nginx |
- 🐳 **容器化** - 完整的 Docker 支持,多种运行模式
- 🔄 **热重载** - 开发模式支持代码热重载
--- ## 快速开始
## 🚀 快速开始 ### Docker Compose 启动(推荐)
### 前置要求
- Docker 20.10+
- Docker Compose 2.0+
- (可选) Make 工具
### 5分钟快速体验
```bash ```bash
# 1. 克隆项目 # 1. 完全停止并清理现有容器
git clone <repository-url> docker compose -f docker-compose.yml -f backend/docker-compose.yml down -v
cd ocdp-go
# 2. 启动开发环境Mock 模式,无需数据库 # 2. 启动所有服务PostgreSQL + Backend + Frontend + Nginx
make docker-dev 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. 查看服务状态
# - 前端http://localhost:5173 docker ps
# - 后端http://localhost:8080
# - 默认账号admin / admin123 # 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
``` ```
**详细指南**:查看 [快速开始指南](./QUICK_START.md) ### 开发环境
--- ```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
- 🚀 [快速开始](./QUICK_START.md) - 5分钟快速上手 make db-init
- 📋 [使用指南](./USAGE_GUIDE.md) - 详细使用说明(推荐)
- 💡 [命令速查表](./COMMANDS_CHEATSHEET.md) - 常用命令快速参考
- 📚 [文档中心](./docs/README.md) - 完整文档索引
### 🔧 专业文档 # 3. 启动后端(需要设置环境变量)
- 📐 [开发规范](./docs/development/specification.md) - 代码规范和架构 cd backend && \
- 🚢 [部署指南](./docs/deployment/docker-guide.md) - 生产环境部署 DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable" \
- 🔒 [安全实践](./docs/security/security-implementation.md) - 安全配置 JWT_SECRET="test-jwt-secret-key" \
- 🎨 [功能文档](./docs/features/) - 详细功能说明 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
- 📋 [OpenAPI 规范](./backend/docs/openapi.yaml) - RESTful API 定义 source ~/.nvm/nvm.sh && nvm use 20
- 📦 [历史文档](./docs/archive/) - 项目演进历史 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
**后端**
- Go 1.24+ (Hexagonal Architecture)
- PostgreSQL 16
- Redis 7
**前端**
- React 18
- TypeScript 5
- Vite 6
- TailwindCSS 3
**容器化**
- Docker
- Docker Compose
- Multi-stage builds
### 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ React + TypeScript + Vite │
└──────────────────────────┬──────────────────────────────────┘
│ HTTP/REST
┌──────────────────────────┼──────────────────────────────────┐
│ │ Backend API │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Input Adapters │ │
│ │ (REST/GraphQL) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Domain Services │ │
│ │ (Business Logic) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Output Adapters │ │
│ │ (Repos/Clients) │ │
│ └──────────┬──────────┘ │
└───────────────────────┼─┴────────────────────────────────┘
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ PG DB │ │ Redis │ │ OCI │
│ │ │ │ │ Registry│
└─────────┘ └─────────┘ └─────────┘
``` ```
### 运行模式 ### 生产环境Docker Compose
| 模式 | 特点 | 适用场景 | 命令 | ```bash
|------|------|----------|------| # 构建并启动所有服务
| **开发模式** | Mock 数据,热重载 | 日常开发 | `make docker-dev` | make run-2
| **生产模式** | 真实数据库,完整功能 | 生产部署 | `make docker-prod` |
| **Mock 模式** | 独立测试单个服务 | 单元测试 | `make docker-test-backend` |
--- # 停止服务
make clean-2
```
## 🛠️ 开发指南 ## 配置
### 项目结构 ### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| DATABASE_URL | postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable | 数据库连接串 |
| PORT | 8080 | 后端端口 |
| BACKEND_PORT | 8080 | Docker 映射端口 |
| JWT_SECRET | change-me-in-production | JWT 密钥 |
| ENCRYPTION_KEY | change-me-32-bytes-long-key-here | 加密密钥 |
| ALLOWED_DEV_ORIGINS | http://10.6.80.114:3000 | 允许的跨域来源(外部访问时需要配置)|
| NEXT_PUBLIC_API_URL | http://10.6.80.114:8080/api/v1 | 前端调用的API地址 |
### 配置文件
项目根目录 `.env` 文件包含默认配置:
- Kubernetes 集群配置
- Harbor 仓库信息
- NFS 存储配置
## 访问
| 服务 | 地址 |
|------|------|
| 前端 (Nginx) | http://10.6.80.114 |
| 后端 API | http://10.6.80.114:8080/api/v1 |
| API 文档 | http://10.6.80.114:8080/api/docs |
**默认账号**: `admin` / `admin123`
## 项目结构
``` ```
ocdp-go/ ocdp-go/
├── backend/ # Go 后端服务 ├── backend/ # Go 后端 (Hexagonal Architecture)
│ ├── cmd/api/ # 应用入口 │ ├── cmd/api/ # 入口
│ ├── internal/ # 内部代码 │ ├── internal/
│ │ ├── adapter/ # 适配器层 │ │ ├── adapter/ # 适配器层 (HTTP, Persistence)
│ │ ├── domain/ # 领域层 │ │ ├── domain/ # 领域层 (Entity, Service, Repository)
│ │ └── bootstrap/ # 启动配置 │ │ └── bootstrap/ # 初始化和种子数据
── Dockerfile # 生产环境 ── scripts/ # 脚本 (init-db.sql)
│ ├── Dockerfile.dev # 开发环境 ├── frontend/ # Next.js 前端
│ └── Dockerfile.mock # Mock 测试
├── frontend/ # React 前端应用
│ ├── src/ │ ├── src/
│ │ ├── core/ # 核心功能 │ │ ├── app/ # 页面路由
│ │ ├── features/ # 功能模块 │ │ ├── components/ # 组件
│ │ └── shared/ # 共享组件 │ │ └── lib/ # 工具库 (API, types, auth)
── Dockerfile # 生产环境 ── .env.local # 前端环境配置
│ ├── Dockerfile.dev # 开发环境 ├── infra/nginx/ # Nginx 配置
│ └── Dockerfile.mock # Mock 测试 ── docker-compose.yml # 主配置
├── backend/docker-compose.yml # 后端配置
── api/ # API 规范 ── Makefile # 构建命令
│ └── openapi.yaml # OpenAPI 定义
├── docs/ # 项目文档
│ ├── features/ # 功能文档
│ ├── deployment/ # 部署文档
│ └── development/ # 开发文档
├── docker-compose.yml # 统一配置(使用 profiles
└── 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
# Docker 服务(推荐) # 启动开发服务器
make docker-dev # 启动开发环境 make dev # 同时启动前后端
make docker-prod # 启动生产环境 make dev-backend # 仅后端
make docker-test-backend # 测试后 make dev-frontend # 仅前
make docker-test-frontend # 测试前端
make docker-logs # 查看日志
make docker-down # 停止服务
# OpenAPI 工作流 # 数据库操作
make openapi-validate # 验证 API 规范 make db-init # 初始化数据库
make openapi-gen # 生成代码 make db-reset # 重置数据库
make openapi-docs # 生成文档 make db-shell # 打开数据库 shell
# 本地开发(不使用 Docker # Docker 构建
make install # 安装依赖 make build # 构建所有镜像
make dev-local # 启动本地开发 make build-backend # 构建后端镜像
make test # 运行测试 make build-frontend # 构建前端镜像
# 日志和调试
make logs # 查看所有日志
make logs-backend # 后端日志
make stop # 停止开发服务器
``` ```
### 开发工作流 ## License
1. **启动开发环境** MIT
```bash
make docker-dev
```
2. **修改代码**(自动热重载):
- 后端:编辑 `backend/` 下的 Go 文件
- 前端:编辑 `frontend/src/` 下的 React 组件
3. **查看日志**
```bash
make docker-logs
```
4. **测试功能**
- 前端http://localhost:5173
- 后端http://localhost:8080
5. **提交代码**
```bash
git add .
git commit -m "feat: add new feature"
git push
```
---
## 🧪 测试
### 后端测试
```bash
# 启动后端 Mock
make docker-test-backend-bg
# 测试健康检查
curl http://localhost:8080/health
# 测试登录
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# 测试 API
curl http://localhost:8080/api/v1/registries
curl http://localhost:8080/api/v1/clusters
```
### 前端测试
```bash
# 启动前端 Mock
make docker-test-frontend-bg
# 访问前端
open http://localhost:3000
```
### 集成测试
```bash
# 启动完整环境
make docker-prod
# 运行测试套件
make test
```
---
## 📦 部署
### Docker Compose 部署(推荐)
```bash
# 1. 配置环境变量
export JWT_SECRET="your-production-secret"
export ENCRYPTION_KEY="your-32-byte-encryption-key"
# 2. 启动服务
docker compose up -d
# 3. 查看状态
docker compose ps
```
### Kubernetes 部署
查看 [Kubernetes 部署指南](./docs/deployment/kubernetes-guide.md)
---
## 🤝 贡献
欢迎贡献代码!请遵循以下步骤:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'feat: add amazing feature'`)
4. 推送分支 (`git push origin feature/amazing-feature`)
5. 创建 Pull Request
### 开发规范
- **代码风格**Go (gofmt)TypeScript (ESLint + Prettier)
- **提交规范**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)
- **测试覆盖**:新功能必须包含测试
---
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
---
## 🙏 致谢
- [Go](https://go.dev/) - 后端开发语言
- [React](https://react.dev/) - 前端框架
- [Vite](https://vitejs.dev/) - 构建工具
- [Docker](https://www.docker.com/) - 容器化平台
- [Kubernetes](https://kubernetes.io/) - 容器编排
- [Harbor](https://goharbor.io/) - OCI Registry
---
## 📞 联系方式
- **项目主页**https://github.com/your-org/ocdp-go
- **问题反馈**https://github.com/your-org/ocdp-go/issues
- **文档网站**https://docs.ocdp.example.com
---
<div align="center">
<sub>Built with ❤️ by the OCDP Team</sub>
</div>

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

@ -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

@ -27,6 +27,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -104,6 +105,20 @@ func main() {
repos.MetricsClient, repos.MetricsClient,
) )
// Workspace Service
workspaceService := service.NewWorkspaceService(
repos.WorkspaceRepo,
repos.QuotaRepo,
repos.UserRepo,
)
// User Management Service
userManagementService := service.NewUserManagementService(
repos.UserRepo,
repos.WorkspaceRepo,
passwordHasher,
)
log.Println("✅ Domain Services initialized") log.Println("✅ Domain Services initialized")
// ===== 6. 加载并执行 Bootstrap 预注入 ===== // ===== 6. 加载并执行 Bootstrap 预注入 =====
@ -128,6 +143,30 @@ func main() {
monitoringHandler := rest.NewMonitoringHandler(monitoringService) monitoringHandler := rest.NewMonitoringHandler(monitoringService)
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. 设置路由 =====
@ -139,6 +178,14 @@ func main() {
instanceHandler, instanceHandler,
monitoringHandler, monitoringHandler,
swaggerHandler, swaggerHandler,
workspaceHandler,
userManagementHandler,
userHandler,
storageHandler,
chartRefHandler,
valuesTemplateHandler,
tokenGenerator,
config.AllowedOrigins,
) )
// ===== 9. 启动服务器 ===== // ===== 9. 启动服务器 =====
@ -161,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,
} }
} }
@ -197,12 +251,66 @@ func setupRouter(
instanceHandler *rest.InstanceHandler, instanceHandler *rest.InstanceHandler,
monitoringHandler *rest.MonitoringHandler, monitoringHandler *rest.MonitoringHandler,
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) {
@ -220,12 +328,39 @@ 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/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)
// ===== 用户账户路由 =====
api.HandleFunc("/users/me", userHandler.GetCurrentUser).Methods(http.MethodGet)
api.HandleFunc("/users/me/password", userHandler.ChangePassword).Methods(http.MethodPut)
api.HandleFunc("/users/me/workspace", userHandler.GetCurrentUserWorkspace).Methods(http.MethodGet)
// ===== 用户管理路由Admin =====
api.HandleFunc("/admin/users", userManagementHandler.CreateUser).Methods(http.MethodPost)
api.HandleFunc("/admin/users", userManagementHandler.ListUsers).Methods(http.MethodGet)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.GetUser).Methods(http.MethodGet)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.UpdateUser).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/active", userManagementHandler.SetUserActive).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/workspace", userManagementHandler.ChangeUserWorkspace).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/password", userManagementHandler.ResetPassword).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.DeleteUser).Methods(http.MethodDelete)
// ===== Workspace 路由 =====
api.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
api.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.GetWorkspace).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.UpdateWorkspace).Methods(http.MethodPut)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.DeleteWorkspace).Methods(http.MethodDelete)
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.GetWorkspaceQuotas).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.SetWorkspaceQuotas).Methods(http.MethodPut)
// ===== 集群路由 ===== // ===== 集群路由 =====
api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost) api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet) api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
@ -242,11 +377,37 @@ func setupRouter(
api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete) api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
api.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet) 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 路由 ===== // ===== Artifact 路由 =====
api.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet) 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", 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}", 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-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values", artifactHandler.GetArtifactValues).Methods(http.MethodGet)
// ===== Instance 路由 ===== // ===== Instance 路由 =====
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost) api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
@ -285,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

@ -61,12 +61,37 @@ services:
image: ocdp-backend:latest image: ocdp-backend:latest
container_name: ocdp-backend container_name: ocdp-backend
restart: unless-stopped restart: unless-stopped
env_file:
- /media/ivanwu/DATA/ocdp-go/.env
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:-8080}:8080" - "${BACKEND_PORT:-8080}:8080"
volumes: volumes:

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

@ -42,3 +42,8 @@ type ValuesSchemaResponse struct {
Schema string `json:"schema"` Schema string `json:"schema"`
} }
// ValuesResponse Values 响应
type ValuesResponse struct {
Values string `json:"values"`
}

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

@ -2,30 +2,36 @@ package dto
// CreateClusterRequest 创建集群请求 // CreateClusterRequest 创建集群请求
type CreateClusterRequest struct { type CreateClusterRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Host string `json:"host" binding:"required"` Host string `json:"host" binding:"required"`
CAData string `json:"caData"` CAData string `json:"caData"`
CADataAlt string `json:"ca_data"` CADataAlt string `json:"ca_data"`
CertData string `json:"certData"` CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"` CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"` KeyData string `json:"keyData"`
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"`
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
IsShared bool `json:"isShared"` // 是否为共享集群
} }
// UpdateClusterRequest 更新集群请求 // UpdateClusterRequest 更新集群请求
type UpdateClusterRequest struct { type UpdateClusterRequest struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
CAData string `json:"caData"` CAData string `json:"caData"`
CADataAlt string `json:"ca_data"` CADataAlt string `json:"ca_data"`
CertData string `json:"certData"` CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"` CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"` KeyData string `json:"keyData"`
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"`
IsolationMode string `json:"isolationMode"`
DefaultNamespace string `json:"defaultNamespace"`
IsShared *bool `json:"isShared"`
} }
// Normalize 将多种命名风格的字段合并到统一字段 // Normalize 将多种命名风格的字段合并到统一字段
@ -56,10 +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"`
Host string `json:"host"`
Description string `json:"description"`
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
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,42 +10,50 @@ 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,
Name: registry.Name, WorkspaceID: registry.WorkspaceID,
URL: registry.URL, OwnerID: registry.OwnerID,
Description: registry.Description, Name: registry.Name,
Username: registry.Username, URL: registry.URL,
Insecure: registry.Insecure, Description: registry.Description,
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), Username: registry.Username,
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), Insecure: registry.Insecure,
IsShared: registry.IsShared,
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
} }
// 脱敏处理密码 // 脱敏处理密码
if registry.Password != "" { if registry.Password != "" {
response.HasPassword = true response.HasPassword = true
response.Password = crypto.MaskSensitiveData(registry.Password) response.Password = crypto.MaskSensitiveData(registry.Password)
} }
return response return response
} }
// ToClusterResponse 转换 Cluster 实体为响应 DTO脱敏 // ToClusterResponse 转换 Cluster 实体为响应 DTO脱敏
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse { func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
response := &ClusterResponse{ response := &ClusterResponse{
ID: cluster.ID, ID: cluster.ID,
Name: cluster.Name, WorkspaceID: cluster.WorkspaceID,
Host: cluster.Host, OwnerID: cluster.OwnerID,
Description: cluster.Description, Name: cluster.Name,
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), Host: cluster.Host,
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), Description: cluster.Description,
IsolationMode: string(cluster.IsolationMode),
DefaultNamespace: cluster.DefaultNamespace,
IsShared: cluster.IsShared,
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
} }
// 设置认证配置状态标志 // 设置认证配置状态标志
response.HasCAData = cluster.CAData != "" response.HasCAData = cluster.CAData != ""
response.HasCertData = cluster.CertData != "" response.HasCertData = cluster.CertData != ""
response.HasKeyData = cluster.KeyData != "" response.HasKeyData = cluster.KeyData != ""
response.HasToken = cluster.Token != "" response.HasToken = cluster.Token != ""
// 脱敏处理敏感数据(仅显示掩码) // 脱敏处理敏感数据(仅显示掩码)
if cluster.CAData != "" { if cluster.CAData != "" {
response.CAData = crypto.MaskSensitiveData(cluster.CAData) response.CAData = crypto.MaskSensitiveData(cluster.CAData)
@ -57,7 +67,87 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
if cluster.Token != "" { if cluster.Token != "" {
response.Token = crypto.MaskSensitiveData(cluster.Token) response.Token = crypto.MaskSensitiveData(cluster.Token)
} }
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

@ -7,7 +7,8 @@ type CreateInstanceRequest struct {
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"`
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"`
@ -26,6 +27,10 @@ func (r *CreateInstanceRequest) Normalize() {
if r.RegistryID == "" { if r.RegistryID == "" {
r.RegistryID = r.RegistryIDAlt r.RegistryID = r.RegistryIDAlt
} }
// Support both "tag" and "version" field names from frontend
if r.Tag == "" {
r.Tag = r.Version
}
} }
// RollbackInstanceRequest 回滚实例请求 // RollbackInstanceRequest 回滚实例请求

View File

@ -23,12 +23,15 @@ type UpdateRegistryRequest struct {
// RegistryResponse Registry 响应(敏感数据已脱敏) // RegistryResponse Registry 响应(敏感数据已脱敏)
type RegistryResponse struct { type RegistryResponse struct {
ID string `json:"id"` ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
Description string `json:"description"` Description string `json:"description"`
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感) Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
Password string `json:"password,omitempty"` // 脱敏显示(••••••••) Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
HasPassword bool `json:"hasPassword"` // 是否已设置密码 HasPassword bool `json:"hasPassword"` // 是否已设置密码
IsShared bool `json:"is_shared"`
Insecure bool `json:"insecure"` Insecure bool `json:"insecure"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`

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

@ -191,3 +191,42 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
respondJSON(w, http.StatusOK, response) respondJSON(w, http.StatusOK, response)
} }
// GetArtifactValues 获取 Helm Chart 的 values.yaml
// @Summary 获取 Helm Chart Values
// @Description 获取 Helm Chart 的 values.yaml 文件内容 (仅支持 Chart 类型)
// @Tags Artifacts
// @Accept json
// @Produce json
// @Param registry_id path string true "Registry ID"
// @Param repository_name path string true "Repository Name (URL encoded)"
// @Param reference path string true "Artifact Reference (tag or digest)"
// @Success 200 {object} dto.ValuesResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values [get]
func (h *ArtifactHandler) GetArtifactValues(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
registryID := vars["registry_id"]
repositoryName := vars["repository_name"]
reference := vars["reference"]
values, err := h.artifactService.GetValues(r.Context(), registryID, repositoryName, reference)
if err != nil {
switch {
case errors.Is(err, entity.ErrRegistryNotFound),
errors.Is(err, entity.ErrRepositoryNotFound),
errors.Is(err, entity.ErrArtifactNotFound),
errors.Is(err, entity.ErrValuesNotFound):
respondError(w, http.StatusNotFound, "Values not found", err.Error())
default:
respondError(w, http.StatusInternalServerError, "Failed to get values", err.Error())
}
return
}
response := &dto.ValuesResponse{
Values: values,
}
respondJSON(w, http.StatusOK, response)
}

View File

@ -83,14 +83,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// 获取用户信息 // 获取用户信息
// TODO: 从 token 解析用户信息或从服务获取 // TODO: 从 token 解析用户信息或从服务获取
// 返回响应 // 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致
response := &dto.AuthResponse{ response := &dto.AuthResponse{
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
Username: req.Username, Username: req.Username,
} }
respondJSON(w, http.StatusOK, response) respondSuccess(w, "Login successful", response)
} }
// RefreshToken 刷新 Token // RefreshToken 刷新 Token
@ -117,11 +117,11 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
return return
} }
// 返回响应 // 返回响应 - 使用 respondSuccess 包装
response := &dto.AuthResponse{ response := &dto.AuthResponse{
AccessToken: newAccessToken, AccessToken: newAccessToken,
RefreshToken: req.RefreshToken, RefreshToken: req.RefreshToken,
} }
respondJSON(w, http.StatusOK, response) respondSuccess(w, "Token refreshed", response)
} }

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,13 +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
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)
@ -57,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
}
} }
// 调用领域服务 // 调用领域服务
@ -198,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

@ -45,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
@ -54,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
@ -180,6 +188,7 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
LastOperation: string(instance.LastOperation), LastOperation: string(instance.LastOperation),
LastError: instance.LastError, LastError: instance.LastError,
Revision: instance.Revision, Revision: instance.Revision,
Values: instance.Values,
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}) })

View File

@ -41,7 +41,7 @@ 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.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

@ -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

@ -0,0 +1,333 @@
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"
)
// WorkspaceHandler 工作空间 HTTP 处理程序
type WorkspaceHandler struct {
workspaceService *service.WorkspaceService
authService *service.AuthService
}
// NewWorkspaceHandler 创建工作空间处理程序
func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService *service.AuthService) *WorkspaceHandler {
return &WorkspaceHandler{
workspaceService: workspaceService,
authService: authService,
}
}
// CreateWorkspace 创建工作空间
// @Summary 创建工作空间
// @Description 创建新的工作空间Admin 专用,支持 cluster_ids 和初始配额)
// @Tags workspace
// @Accept json
// @Produce json
// @Param request body dto.CreateWorkspaceRequest true "创建工作空间请求"
// @Success 200 {object} dto.WorkspaceDTO
// @Router /workspaces [post]
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
var req dto.CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
// 获取创建者 ID
userID := GetUserIDFromRequest(r)
// 准备配额
quotas := make(map[entity.ResourceType]struct {
HardLimit float64
SoftLimit float64
})
if req.CPU != nil {
quotas[entity.ResourceCPU] = struct {
HardLimit float64
SoftLimit float64
}{req.CPU.HardLimit, req.CPU.SoftLimit}
}
if req.GPU != nil {
quotas[entity.ResourceGPU] = struct {
HardLimit float64
SoftLimit float64
}{req.GPU.HardLimit, req.GPU.SoftLimit}
}
if req.GPUMemory != nil {
quotas[entity.ResourceGPUMemory] = struct {
HardLimit float64
SoftLimit float64
}{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit}
}
workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID, req.ClusterIDs, quotas)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
}
// GetWorkspace 获取工作空间
// @Summary 获取工作空间
// @Description 获取指定工作空间的详细信息和配额
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Success 200 {object} dto.WorkspaceResponse
// @Router /workspaces/{workspace_id} [get]
func (h *WorkspaceHandler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusNotFound, "Workspace not found", "")
return
}
// 检查访问权限
if !h.canAccessWorkspace(w, r, workspace.ID) {
return
}
// 获取配额
quotas, _ := h.workspaceService.GetQuotas(r.Context(), workspace.ID)
response := dto.WorkspaceResponse{
Workspace: dto.WorkspaceDTOFromEntity(workspace),
Quotas: dto.QuotaDTOsFromEntities(quotas),
}
respondSuccess(w, "", response)
}
// UpdateWorkspace 更新工作空间
// @Summary 更新工作空间
// @Description 更新工作空间信息Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Param request body dto.UpdateWorkspaceRequest true "更新工作空间请求"
// @Success 200 {object} dto.WorkspaceDTO
// @Router /workspaces/{workspace_id} [put]
func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusNotFound, "Workspace not found", "")
return
}
var req dto.UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if req.Name != "" {
workspace.Name = req.Name
}
if req.Description != "" {
workspace.Description = req.Description
}
if req.ClusterIDs != nil {
workspace.ClusterIDs = req.ClusterIDs
}
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
}
// 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),
})
}
// GetWorkspaceQuotas 获取工作空间配额
// @Summary 获取工作空间配额
// @Description 获取指定工作空间的资源配额
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Success 200 {array} dto.QuotaDTO
// @Router /workspaces/{workspace_id}/quotas [get]
func (h *WorkspaceHandler) GetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
// 检查访问权限
if !h.canAccessWorkspace(w, r, workspaceID) {
return
}
quotas, err := h.workspaceService.GetQuotas(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error(), "")
return
}
respondSuccess(w, "", dto.QuotaDTOsFromEntities(quotas))
}
// SetWorkspaceQuotas 设置工作空间配额
// @Summary 设置工作空间配额
// @Description 设置指定工作空间的 CPU/GPU/GPU Memory 配额Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Param request body dto.SetQuotasRequest true "配额设置请求"
// @Success 200 {array} dto.QuotaDTO
// @Router /workspaces/{workspace_id}/quotas [put]
func (h *WorkspaceHandler) SetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
var req dto.SetQuotasRequest
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))
}
// requireAdmin 检查是否为 Admin
func (h *WorkspaceHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
userRole := r.Header.Get("X-User-Role")
if userRole != string(entity.RoleAdmin) {
respondError(w, http.StatusForbidden, "Admin access required", "")
return false
}
return true
}
// canAccessWorkspace 检查是否可以访问工作空间
func (h *WorkspaceHandler) canAccessWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
userRole := r.Header.Get("X-User-Role")
userWorkspaceID := r.Header.Get("X-Workspace-ID")
// Admin 可以访问所有
if userRole == string(entity.RoleAdmin) {
return true
}
// 普通用户只能访问自己的 workspace
if userWorkspaceID != workspaceID {
respondError(w, http.StatusForbidden, "Access denied", "")
return false
}
return true
}

View File

@ -127,6 +127,69 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
return k8s.NewEntryClient() return k8s.NewEntryClient()
} }
// CreateWorkspaceRepository 创建 Workspace 仓储
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("workspace repository mock not implemented")
}
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewWorkspaceRepository(f.db), nil
}
// CreateQuotaRepository 创建 Quota 仓储
// CreateStorageRepository 创建存储后端仓储
func (f *AdapterFactory) CreateStorageRepository() (repository.StorageRepository, error) {
if f.mode == ModeMock {
return mock.NewStorageRepositoryMock(), nil
}
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
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) { func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
userRepo, err := f.CreateUserRepository() userRepo, err := f.CreateUserRepository()
@ -149,6 +212,21 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
return nil, fmt.Errorf("failed to create instance repository: %w", err) return nil, fmt.Errorf("failed to create instance repository: %w", err)
} }
workspaceRepo, err := f.CreateWorkspaceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create workspace repository: %w", err)
}
storageRepo, err := f.CreateStorageRepository()
if err != nil {
return nil, fmt.Errorf("failed to create storage repository: %w", err)
}
quotaRepo, err := f.CreateQuotaRepository()
if err != nil {
return nil, fmt.Errorf("failed to create quota repository: %w", err)
}
ociClient, err := f.CreateOCIClient() ociClient, err := f.CreateOCIClient()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create OCI client: %w", err) return nil, fmt.Errorf("failed to create OCI client: %w", err)
@ -163,28 +241,48 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
metricsClient := f.CreateMetricsClient(clusterRepo) metricsClient := f.CreateMetricsClient(clusterRepo)
entryClient := f.CreateEntryClient() entryClient := f.CreateEntryClient()
chartRefRepo, err := f.CreateChartReferenceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create chart reference repository: %w", err)
}
valuesTemplateRepo, err := f.CreateValuesTemplateRepository()
if err != nil {
return nil, fmt.Errorf("failed to create values template repository: %w", err)
}
return &Repositories{ return &Repositories{
UserRepo: userRepo, UserRepo: userRepo,
ClusterRepo: clusterRepo, ClusterRepo: clusterRepo,
RegistryRepo: registryRepo, RegistryRepo: registryRepo,
InstanceRepo: instanceRepo, InstanceRepo: instanceRepo,
OCIClient: ociClient, WorkspaceRepo: workspaceRepo,
HelmClient: helmClient, StorageRepo: storageRepo,
MetricsClient: metricsClient, ChartRefRepo: chartRefRepo,
EntryClient: entryClient, ValuesTemplateRepo: valuesTemplateRepo,
QuotaRepo: quotaRepo,
OCIClient: ociClient,
HelmClient: helmClient,
MetricsClient: metricsClient,
EntryClient: entryClient,
}, nil }, nil
} }
// Repositories 所有仓储的集合 // Repositories 所有仓储的集合
type Repositories struct { type Repositories struct {
UserRepo repository.UserRepository UserRepo repository.UserRepository
ClusterRepo repository.ClusterRepository ClusterRepo repository.ClusterRepository
RegistryRepo repository.RegistryRepository RegistryRepo repository.RegistryRepository
InstanceRepo repository.InstanceRepository InstanceRepo repository.InstanceRepository
OCIClient repository.OCIClient WorkspaceRepo repository.WorkspaceRepository
HelmClient repository.HelmClient StorageRepo repository.StorageRepository
MetricsClient repository.MetricsClient ChartRefRepo repository.ChartReferenceRepository
EntryClient repository.InstanceEntryClient ValuesTemplateRepo repository.ValuesTemplateRepository
QuotaRepo repository.QuotaRepository
OCIClient repository.OCIClient
HelmClient repository.HelmClient
MetricsClient repository.MetricsClient
EntryClient repository.InstanceEntryClient
} }
// ensureDBConnection 确保数据库连接已建立 // ensureDBConnection 确保数据库连接已建立

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -113,22 +114,24 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
install.Namespace = instance.Namespace install.Namespace = instance.Namespace
install.CreateNamespace = true install.CreateNamespace = true
install.Wait = true install.Wait = true
install.Timeout = 5 * time.Minute 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

View File

@ -262,12 +262,40 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
return mockSchema, nil return mockSchema, nil
} }
func (c *OCIClientMock) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
artifact, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil {
return "", err
}
if !artifact.IsChart() {
return "", fmt.Errorf("not a helm chart")
}
// 返回 Mock values.yaml
mockValues := `# Default values for the chart
replicaCount: 1
image:
repository: nginx
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources: {}
`
return mockValues, nil
}
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 {
_, err := c.GetArtifact(ctx, registry, repository, reference) _, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil { if err != nil {
return err return err
} }
// Mock 实现,不实际下载 // Mock 实现,不实际下载
return nil return nil
} }

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -43,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,
}), }),
} }
} }
@ -61,23 +75,147 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
} }
// ListRepositories 列出 Registry 中的所有 repositories // ListRepositories 列出 Registry 中的所有 repositories
// 优先使用 OCI _catalog API失败时回退到 Harbor REST API v2
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) { func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
reg, err := c.getRegistry(registry)
if err != nil {
return nil, err
}
repositories := make([]string, 0) repositories := make([]string, 0)
err = reg.Repositories(ctx, "", func(repos []string) error { // 尝试 OCI _catalog API
repositories = append(repositories, repos...) reg, err := c.getRegistry(registry)
return nil 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
}
// 回退: 使用 Harbor REST API v2
log.Printf("[Harbor Fallback] OCI failed (err=%v, repos=%d), checking if Harbor...", err, len(repositories))
log.Printf("[Harbor Fallback] registry.URL=%s, contains 'harbor'=%v", registry.URL, strings.Contains(registry.URL, "harbor"))
if strings.Contains(registry.URL, "harbor") {
log.Printf("[Harbor Fallback] Yes, this is Harbor! Calling Harbor REST API...")
repos, fallbackErr := c.listHarborRepositories(registry)
log.Printf("[Harbor Fallback] Got %d repos, err=%v", len(repos), fallbackErr)
if fallbackErr == nil && len(repos) > 0 {
log.Printf("[Harbor Fallback] Returning %d repos from Harbor API", len(repos))
return repos, nil
}
if err != nil {
return nil, fmt.Errorf("failed to list repositories: %w", err)
}
return nil, fallbackErr
}
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
}
// listHarborRepositories 使用 Harbor REST API v2 获取仓库列表
func (c *OCIClient) listHarborRepositories(registry *entity.Registry) ([]string, error) {
// 解析 Harbor URL 基础地址
baseURL := registry.URL
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = strings.TrimPrefix(baseURL, "https://")
baseURL = strings.TrimPrefix(baseURL, "http://")
harborHost := "https://" + baseURL
// 获取认证信息
username := registry.Username
password := registry.Password
if username == "" || password == "" {
username = os.Getenv("HARBOR_USERNAME")
password = os.Getenv("HARBOR_PASSWORD")
}
// 获取项目列表
projectsURL := harborHost + "/api/v2.0/projects"
req, err := http.NewRequest("GET", projectsURL, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to list projects: status %d", resp.StatusCode)
}
var projects []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
return nil, err
}
repositories := make([]string, 0)
pageSize := 100
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++
}
}
log.Printf("[listHarborRepositories] Total repos collected: %d", len(repositories))
return repositories, nil return repositories, nil
} }
@ -370,6 +508,105 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
return "", entity.ErrValuesSchemaNotFound return "", entity.ErrValuesSchemaNotFound
} }
// GetValues 获取 Helm Chart 的 values.yaml
func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
reg, err := c.getRegistry(registry)
if err != nil {
return "", err
}
repo, err := reg.Repository(ctx, repository)
if err != nil {
return "", fmt.Errorf("failed to get repository: %w", err)
}
// 解析 reference (tag 或 digest)
desc, err := repo.Resolve(ctx, reference)
if err != nil {
return "", fmt.Errorf("failed to resolve artifact: %w", err)
}
manifestReader, err := repo.Fetch(ctx, desc)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
defer manifestReader.Close()
manifestBytes, err := io.ReadAll(manifestReader)
if err != nil {
return "", fmt.Errorf("failed to read manifest: %w", err)
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
}
// 查找 Helm Chart layertar+gzip 包含 chart 内容)并从中读取 values.yaml
var chartLayer *ocispec.Descriptor
for i := range manifest.Layers {
layer := manifest.Layers[i]
if strings.Contains(layer.MediaType, "cncf.helm.chart") ||
strings.Contains(layer.MediaType, "helm.chart.content") {
chartLayer = &manifest.Layers[i]
break
}
}
if chartLayer == nil {
return "", entity.ErrValuesNotFound
}
if chartLayer.Digest == "" {
return "", fmt.Errorf("chart layer digest is empty")
}
if _, err := digest.Parse(string(chartLayer.Digest)); err != nil {
return "", fmt.Errorf("invalid chart layer digest: %w", err)
}
layerReader, err := repo.Fetch(ctx, *chartLayer)
if err != nil {
return "", fmt.Errorf("failed to fetch chart layer: %w", err)
}
defer layerReader.Close()
gzipReader, err := gzip.NewReader(layerReader)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read chart archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
// 查找 values.yaml 文件(可能在 chart 根目录或子目录中)
// 通常路径格式为: {chart-name}/values.yaml
if strings.HasSuffix(header.Name, "values.yaml") {
data, err := io.ReadAll(tarReader)
if err != nil {
return "", fmt.Errorf("failed to read values.yaml: %w", err)
}
if len(data) == 0 {
return "", entity.ErrValuesNotFound
}
return string(data), nil
}
}
return "", entity.ErrValuesNotFound
}
// PullArtifact 下载 artifact 到本地 // PullArtifact 下载 artifact 到本地
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error { func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
reg, err := c.getRegistry(registry) reg, err := c.getRegistry(registry)

View File

@ -104,13 +104,41 @@ func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) { func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0, len(r.clusters)) clusters := make([]*entity.Cluster, 0, len(r.clusters))
for _, cluster := range r.clusters { for _, cluster := range r.clusters {
// 解密敏感数据后返回 // 解密敏感数据后返回
clusters = append(clusters, r.decryptCluster(cluster)) clusters = append(clusters, r.decryptCluster(cluster))
} }
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 return clusters, nil
} }

View File

@ -102,12 +102,26 @@ func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID st
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) { func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0, len(r.instances)) instances := make([]*entity.Instance, 0, len(r.instances))
for _, instance := range r.instances { for _, instance := range r.instances {
instances = append(instances, instance) instances = append(instances, instance)
} }
return instances, nil
}
func (r *InstanceRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0)
for _, instance := range r.instances {
if instance.WorkspaceID == workspaceID {
instances = append(instances, instance)
}
}
return instances, nil return instances, nil
} }

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

@ -88,12 +88,40 @@ func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) { func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
users := make([]*entity.User, 0, len(r.users)) users := make([]*entity.User, 0, len(r.users))
for _, user := range r.users { for _, user := range r.users {
users = append(users, user) users = append(users, user)
} }
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 return users, 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"
@ -32,6 +33,11 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
cluster.ID = uuid.New().String() cluster.ID = uuid.New().String()
} }
// 设置默认值
if cluster.IsolationMode == "" {
cluster.IsolationMode = entity.IsolationModeNamespace
}
// 加密敏感数据 // 加密敏感数据
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData) encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
if err != nil { if err != nil {
@ -54,12 +60,14 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
} }
query := ` query := `
INSERT INTO clusters (id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at) INSERT INTO clusters (id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
` `
_, err = r.db.conn.ExecContext(ctx, query, _, err = r.db.conn.ExecContext(ctx, query,
cluster.ID, cluster.ID,
cluster.WorkspaceID,
cluster.OwnerID,
cluster.Name, cluster.Name,
cluster.Host, cluster.Host,
encryptedCAData, encryptedCAData,
@ -67,6 +75,9 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData, encryptedKeyData,
encryptedToken, encryptedToken,
cluster.Description, cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.CreatedAt, cluster.CreatedAt,
cluster.UpdatedAt, cluster.UpdatedAt,
) )
@ -81,7 +92,7 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
// GetByID 根据 ID 获取集群 // 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) {
query := ` query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at 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 FROM clusters
WHERE id = $1 WHERE id = $1
` `
@ -91,6 +102,8 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
err := r.db.conn.QueryRowContext(ctx, query, id).Scan( err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&cluster.ID, &cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name, &cluster.Name,
&cluster.Host, &cluster.Host,
&encryptedCAData, &encryptedCAData,
@ -98,6 +111,9 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
&encryptedKeyData, &encryptedKeyData,
&encryptedToken, &encryptedToken,
&cluster.Description, &cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt, &cluster.CreatedAt,
&cluster.UpdatedAt, &cluster.UpdatedAt,
) )
@ -109,26 +125,11 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
return nil, fmt.Errorf("failed to get cluster: %w", err) return nil, fmt.Errorf("failed to get cluster: %w", err)
} }
// 解密敏感数据 // 解密敏感数据(检测 kubeconfig 格式则跳过解密)
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData) cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
if err != nil { cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
return nil, fmt.Errorf("failed to decrypt CA data: %w", err) cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
} cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
return cluster, nil return cluster, nil
} }
@ -136,7 +137,7 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
// GetByName 根据名称获取集群 // GetByName 根据名称获取集群
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) { func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
query := ` query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at 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 FROM clusters
WHERE name = $1 WHERE name = $1
` `
@ -146,6 +147,8 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
err := r.db.conn.QueryRowContext(ctx, query, name).Scan( err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&cluster.ID, &cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name, &cluster.Name,
&cluster.Host, &cluster.Host,
&encryptedCAData, &encryptedCAData,
@ -153,6 +156,9 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
&encryptedKeyData, &encryptedKeyData,
&encryptedToken, &encryptedToken,
&cluster.Description, &cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt, &cluster.CreatedAt,
&cluster.UpdatedAt, &cluster.UpdatedAt,
) )
@ -164,30 +170,35 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
return nil, fmt.Errorf("failed to get cluster: %w", err) return nil, fmt.Errorf("failed to get cluster: %w", err)
} }
// 解密敏感数据 // 解密敏感数据(检测 kubeconfig 格式则跳过解密)
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData) cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
if err != nil { cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
return nil, fmt.Errorf("failed to decrypt CA data: %w", err) cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
} cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
return cluster, nil return cluster, nil
} }
// decryptIfNeeded 解密数据。如果数据以 "apiVersion:" 或 "kind:" 开头kubeconfig 格式),
// 则跳过解密直接返回原值。
func (r *ClusterRepository) decryptIfNeeded(data string, fieldName string) string {
if data == "" {
return ""
}
// 检测 kubeconfig 格式(明文 YAML
if (len(data) > 10 && data[:11] == "apiVersion:") ||
(len(data) > 5 && data[:5] == "kind:") {
return data
}
// 否则尝试解密
decrypted, err := r.encryptor.Decrypt(data)
if err != nil {
log.Printf("[ClusterRepository] WARNING: failed to decrypt %s for field %s: %v (field will be empty)", data[:min(50, len(data))], fieldName, err)
return ""
}
return decrypted
}
// Update 更新集群 // Update 更新集群
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error { func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
cluster.UpdatedAt = time.Now() cluster.UpdatedAt = time.Now()
@ -215,9 +226,10 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
query := ` query := `
UPDATE clusters UPDATE clusters
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5, SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
token = $6, description = $7, updated_at = $8 token = $6, description = $7, isolation_mode = $8, default_namespace = $9,
WHERE id = $9 is_shared = $10, updated_at = $11
WHERE id = $12
` `
result, err := r.db.conn.ExecContext(ctx, query, result, err := r.db.conn.ExecContext(ctx, query,
@ -228,6 +240,9 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData, encryptedKeyData,
encryptedToken, encryptedToken,
cluster.Description, cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.UpdatedAt, cluster.UpdatedAt,
cluster.ID, cluster.ID,
) )
@ -272,7 +287,7 @@ func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
// List 列出所有集群 // List 列出所有集群
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) { func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
query := ` query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at 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 FROM clusters
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -283,13 +298,59 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
} }
defer rows.Close() 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) clusters := make([]*entity.Cluster, 0)
for rows.Next() { for rows.Next() {
cluster := &entity.Cluster{} cluster := &entity.Cluster{}
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string var (
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
workspaceID, ownerID, defaultNamespace sql.NullString
)
err := rows.Scan( err := rows.Scan(
&cluster.ID, &cluster.ID,
&workspaceID,
&ownerID,
&cluster.Name, &cluster.Name,
&cluster.Host, &cluster.Host,
&encryptedCAData, &encryptedCAData,
@ -297,6 +358,9 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
&encryptedKeyData, &encryptedKeyData,
&encryptedToken, &encryptedToken,
&cluster.Description, &cluster.Description,
&cluster.IsolationMode,
&defaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt, &cluster.CreatedAt,
&cluster.UpdatedAt, &cluster.UpdatedAt,
) )
@ -304,25 +368,23 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
return nil, fmt.Errorf("failed to scan cluster: %w", err) return nil, fmt.Errorf("failed to scan cluster: %w", err)
} }
// 解密敏感数据 // 处理 NULL 值
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData) cluster.WorkspaceID = workspaceID.String
if err != nil { cluster.OwnerID = ownerID.String
return nil, fmt.Errorf("failed to decrypt CA data: %w", err) cluster.DefaultNamespace = defaultNamespace.String
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData) // 解密敏感数据(检测 kubeconfig 格式则跳过解密)
if err != nil { if encryptedCAData.Valid {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err) cluster.CAData = r.decryptIfNeeded(encryptedCAData.String, "ca_data")
} }
if encryptedCertData.Valid {
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData) cluster.CertData = r.decryptIfNeeded(encryptedCertData.String, "cert_data")
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
} }
if encryptedKeyData.Valid {
cluster.Token, err = r.encryptor.Decrypt(encryptedToken) cluster.KeyData = r.decryptIfNeeded(encryptedKeyData.String, "key_data")
if err != nil { }
return nil, fmt.Errorf("failed to decrypt token: %w", err) if encryptedToken.Valid {
cluster.Token = r.decryptIfNeeded(encryptedToken.String, "token")
} }
clusters = append(clusters, cluster) clusters = append(clusters, cluster)

View File

@ -124,6 +124,58 @@ func (db *DB) InitSchema() error {
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);
-- Storage Backends 表
CREATE TABLE IF NOT EXISTS storage_backends (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
config JSONB NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
is_shared BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id);
-- Chart References 表
CREATE TABLE IF NOT EXISTS chart_references (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
registry_id VARCHAR(36),
repository VARCHAR(500) NOT NULL,
chart_name VARCHAR(255) NOT NULL,
description TEXT,
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chart_workspace ON chart_references(workspace_id);
CREATE INDEX IF NOT EXISTS idx_chart_registry ON chart_references(registry_id);
-- Values Templates 表 - 使用复合唯一键替代主键,允许同一模板的多个版本
CREATE TABLE IF NOT EXISTS values_templates (
id VARCHAR(36),
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
chart_reference_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
description TEXT,
values_yaml TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE,
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

@ -431,3 +431,105 @@ func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, erro
return instances, nil 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

@ -65,22 +65,25 @@ func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Regist
// GetByID 根据 ID 获取 Registry // 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) {
query := ` query := `
SELECT id, 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
WHERE id = $1 WHERE id = $1
` `
registry := &entity.Registry{} registry := &entity.Registry{}
var encryptedPassword string var encryptedPassword, workspaceID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan( err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&registry.ID, &registry.ID,
&workspaceID,
&ownerID,
&registry.Name, &registry.Name,
&registry.URL, &registry.URL,
&registry.Description, &registry.Description,
&registry.Username, &registry.Username,
&encryptedPassword, &encryptedPassword,
&registry.Insecure, &registry.Insecure,
&registry.IsShared,
&registry.CreatedAt, &registry.CreatedAt,
&registry.UpdatedAt, &registry.UpdatedAt,
) )
@ -92,10 +95,12 @@ func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Re
return nil, fmt.Errorf("failed to get registry: %w", err) return nil, fmt.Errorf("failed to get registry: %w", err)
} }
// 解密密码 registry.WorkspaceID = workspaceID.String
registry.Password, err = r.encryptor.Decrypt(encryptedPassword) registry.OwnerID = ownerID.String
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err) // 解密密码(如果失败则保持为空,与 List 行为一致)
if encryptedPassword.Valid {
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
} }
return registry, nil return registry, nil
@ -104,22 +109,25 @@ func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Re
// GetByName 根据名称获取 Registry // GetByName 根据名称获取 Registry
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) { func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
query := ` query := `
SELECT id, 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
WHERE name = $1 WHERE name = $1
` `
registry := &entity.Registry{} registry := &entity.Registry{}
var encryptedPassword string var encryptedPassword, workspaceID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, name).Scan( err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&registry.ID, &registry.ID,
&workspaceID,
&ownerID,
&registry.Name, &registry.Name,
&registry.URL, &registry.URL,
&registry.Description, &registry.Description,
&registry.Username, &registry.Username,
&encryptedPassword, &encryptedPassword,
&registry.Insecure, &registry.Insecure,
&registry.IsShared,
&registry.CreatedAt, &registry.CreatedAt,
&registry.UpdatedAt, &registry.UpdatedAt,
) )
@ -131,10 +139,12 @@ func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entit
return nil, fmt.Errorf("failed to get registry: %w", err) return nil, fmt.Errorf("failed to get registry: %w", err)
} }
// 解密密码 registry.WorkspaceID = workspaceID.String
registry.Password, err = r.encryptor.Decrypt(encryptedPassword) registry.OwnerID = ownerID.String
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err) // 解密密码(如果失败则保持为空,与 List 行为一致)
if encryptedPassword.Valid {
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
} }
return registry, nil return registry, nil
@ -208,7 +218,7 @@ func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
// List 列出所有 Registries // 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, 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
` `
@ -222,16 +232,19 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
registries := make([]*entity.Registry, 0) registries := make([]*entity.Registry, 0)
for rows.Next() { for rows.Next() {
registry := &entity.Registry{} registry := &entity.Registry{}
var encryptedPassword string var encryptedPassword, workspaceID, ownerID sql.NullString
err := rows.Scan( err := rows.Scan(
&registry.ID, &registry.ID,
&workspaceID,
&ownerID,
&registry.Name, &registry.Name,
&registry.URL, &registry.URL,
&registry.Description, &registry.Description,
&registry.Username, &registry.Username,
&encryptedPassword, &encryptedPassword,
&registry.Insecure, &registry.Insecure,
&registry.IsShared,
&registry.CreatedAt, &registry.CreatedAt,
&registry.UpdatedAt, &registry.UpdatedAt,
) )
@ -239,10 +252,13 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
return nil, fmt.Errorf("failed to scan registry: %w", err) return nil, fmt.Errorf("failed to scan registry: %w", err)
} }
// 处理 NULL 值
registry.WorkspaceID = workspaceID.String
registry.OwnerID = ownerID.String
// 解密密码 // 解密密码
registry.Password, err = r.encryptor.Decrypt(encryptedPassword) if encryptedPassword.Valid {
if err != nil { registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
return nil, fmt.Errorf("failed to decrypt password: %w", err)
} }
registries = append(registries, registry) registries = append(registries, registry)

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,9 +28,14 @@ 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, 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
` `
_, err := r.db.conn.ExecContext(ctx, query, _, err := r.db.conn.ExecContext(ctx, query,
@ -37,6 +43,10 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.Username, user.Username,
user.PasswordHash, user.PasswordHash,
user.Email, user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter, user.RevokedAfter,
user.CreatedAt, user.CreatedAt,
user.UpdatedAt, user.UpdatedAt,
@ -52,22 +62,34 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
// GetByID 根据 ID 获取用户 // GetByID 根据 ID 获取用户
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) { func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
query := ` query := `
SELECT id, username, password_hash, email, 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 id = $1 WHERE id = $1
` `
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,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter, &user.RevokedAfter,
&user.CreatedAt, &user.CreatedAt,
&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
} }
@ -80,30 +102,50 @@ 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, 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,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter, &user.RevokedAfter,
&user.CreatedAt, &user.CreatedAt,
&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
} }
@ -113,14 +155,18 @@ 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, revoked_after = $4, updated_at = $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
WHERE id = $6 WHERE id = $10
` `
result, err := r.db.conn.ExecContext(ctx, query, result, err := r.db.conn.ExecContext(ctx, query,
user.Username, user.Username,
user.PasswordHash, user.PasswordHash,
user.Email, user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter, user.RevokedAfter,
user.UpdatedAt, user.UpdatedAt,
user.ID, user.ID,
@ -166,7 +212,7 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error {
// List 列出所有用户 // List 列出所有用户
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) { func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
query := ` query := `
SELECT id, username, password_hash, email, 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
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -185,6 +231,98 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
&user.Username, &user.Username,
&user.PasswordHash, &user.PasswordHash,
&user.Email, &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
}
// 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.RevokedAfter,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,

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

@ -0,0 +1,197 @@
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"
)
// WorkspaceRepository PostgreSQL Workspace 仓储实现
type WorkspaceRepository struct {
db *DB
}
// NewWorkspaceRepository 创建 PostgreSQL Workspace 仓储
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
return &WorkspaceRepository{db: db}
}
// Create 创建 Workspace
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
query := `
INSERT INTO workspaces (id, name, description, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.conn.ExecContext(ctx, query,
workspace.ID,
workspace.Name,
workspace.Description,
workspace.CreatedBy,
workspace.CreatedAt,
workspace.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create workspace: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Workspace
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
WHERE id = $1
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
return workspace, nil
}
// 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 {
workspace.UpdatedAt = time.Now()
query := `
UPDATE workspaces
SET name = $1, description = $2, updated_at = $3
WHERE id = $4
`
result, err := r.db.conn.ExecContext(ctx, query,
workspace.Name,
workspace.Description,
workspace.UpdatedAt,
workspace.ID,
)
if err != nil {
return fmt.Errorf("failed to update workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// Delete 删除 Workspace
func (r *WorkspaceRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM workspaces WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// List 列出所有 Workspace
func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list workspaces: %w", err)
}
defer rows.Close()
workspaces := make([]*entity.Workspace, 0)
for rows.Next() {
workspace := &entity.Workspace{}
err := rows.Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan workspace: %w", err)
}
workspaces = append(workspaces, workspace)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return workspaces, nil
}

View File

@ -5,14 +5,15 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// BootstrapConfig 预注入配置 // BootstrapConfig 预注入配置
type BootstrapConfig struct { type BootstrapConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Users []UserSeed `json:"users"` Users []UserSeed `json:"users"`
Registries []RegistrySeed `json:"registries"` Registries []RegistrySeed `json:"registries"`
Clusters []ClusterSeed `json:"clusters"` Clusters []ClusterSeed `json:"clusters"`
} }
// UserSeed 用户预注入数据 // UserSeed 用户预注入数据
@ -45,11 +46,11 @@ type ClusterSeed struct {
// LoadBootstrapConfig 加载预注入配置 // LoadBootstrapConfig 加载预注入配置
// 支持从文件或环境变量加载 // 支持从文件或环境变量加载
// //
// 加载优先级: // 加载优先级:
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级) // 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
// 2. Mock 模式: 配置文件 config/bootstrap.json // 2. Mock 模式: 配置文件 config/bootstrap.json
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据 // 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取
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 != "" {
@ -62,7 +63,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
// 2. 检查适配器模式 // 2. 检查适配器模式
adapterMode := os.Getenv("ADAPTER_MODE") adapterMode := os.Getenv("ADAPTER_MODE")
// Mock 模式: 使用配置文件(假数据) // Mock 模式: 使用配置文件(假数据)
if adapterMode == "mock" { if adapterMode == "mock" {
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE") configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
@ -89,49 +90,87 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
return &config, nil return &config, nil
} }
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据 // 3. 真实模式 (mode 1, mode 2): 从 .env 读取
return GetDefaultBootstrapConfig(), nil return GetDefaultBootstrapConfig(), nil
} }
// GetDefaultBootstrapConfig 获取默认的预注入配置(示例) // GetDefaultBootstrapConfig 从 .env 加载 bootstrap 数据。
// 支持 BOOTSTRAP_CLUSTERS (逗号分隔的集群名) 以及每个集群的
// BOOTSTRAP_CLUSTER_<NAME>_HOST, _CA, _CERT, _KEY, _DESC。
// 支持 BOOTSTRAP_REGISTRY_* 环境变量。
// 支持 BOOTSTRAP_ADMIN_USER/PASS/EMAIL。
func GetDefaultBootstrapConfig() *BootstrapConfig { func GetDefaultBootstrapConfig() *BootstrapConfig {
// Load clusters from .env (comma-separated list of cluster names)
clusterStr := os.Getenv("BOOTSTRAP_CLUSTERS")
var clusterSeeds []ClusterSeed
if clusterStr != "" {
clusterNames := strings.Split(clusterStr, ",")
for _, name := range clusterNames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
key := sanitizeEnvKey(name)
host := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_HOST")
ca := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CA")
cert := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CERT")
keyData := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_KEY")
desc := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_DESC")
if host != "" {
clusterSeeds = append(clusterSeeds, ClusterSeed{
Name: name,
Host: host,
Description: desc,
CAData: ca,
CertData: cert,
KeyData: keyData,
})
}
}
}
// Load registry from .env
var registrySeeds []RegistrySeed
regName := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_NAME"))
regURL := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_URL"))
if regName != "" && regURL != "" {
registrySeeds = append(registrySeeds, RegistrySeed{
Name: regName,
URL: regURL,
Description: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_DESC")),
Username: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_USER")),
Password: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_PASS")),
Insecure: strings.ToLower(strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_INSECURE"))) == "true",
})
}
// Load users from .env
var userSeeds []UserSeed
adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER"))
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS"))
if adminUser != "" {
userSeeds = append(userSeeds, UserSeed{
Username: adminUser,
Password: adminPass,
Email: strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_EMAIL")),
})
}
return &BootstrapConfig{ return &BootstrapConfig{
Enabled: true, Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0,
Users: []UserSeed{ Users: userSeeds,
{ Registries: registrySeeds,
Username: "admin", Clusters: clusterSeeds,
Password: "admin123",
Email: "admin@example.com",
},
},
Registries: []RegistrySeed{
{
Name: "harbor-bwgdi",
URL: "https://harbor.bwgdi.com",
Description: "BWGDI Harbor Registry",
Username: "admin",
Password: "BWGDIP@ssw0rd1401#",
Insecure: false,
},
},
Clusters: []ClusterSeed{
{
Name: "cluster1",
Host: "https://10.6.14.123:6443",
Description: "K3s Cluster 1",
CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTlRVME9ETTJOemt3SGhjTk1qVXdPREU0TURJeU1URTVXaGNOTXpVd09ERTJNREl5TVRFNQpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTlRVME9ETTJOemt3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFTaVBJUW5LZXR2VjQ3cHUyLytMV1lZaGJjbUY3V3RZQnArOGxDaUVKdkcKaFAyaE5BWVVmZDUrRnN5VVN3bDBTV3NoT3BORmRMc0NzY3pISkhycUpWYUVvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVTlCa3lhSGpPVG1RM29LYWlOaXFmCjVwZTF4L293Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnTzR4M3EyNmhhL1Z0NTRCT1Awc1hVNGt5ckVpNDR6TUcKc0d0Z25LY0NLbk1DSVFEcVhsSzBqSGNKSVE2bTRWanRub0VQWGdzQ2JrdW45WmxvVmxhbWtPNXAzZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrakNDQVRlZ0F3SUJBZ0lJVjVQT1FRblJoSGd3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOelUxTkRnek5qYzVNQjRYRFRJMU1EZ3hPREF5TWpFeE9Wb1hEVEkyTURneApPREF5TWpFeE9Wb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJMTjcrbjNXRDY0TThTMEEKT1Bpd2hReFZRNWdLTStRTk11REFzSlM1UVZFdTIyajZwaFlQYTNyQWFLU1hnZE1EdVYvbTRUamxTQmxCM2dJQwpnZW5wdTc2alNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCVGlxTWRFM0xYbElwVHRiREJnN0ZVcmV1NHVVREFLQmdncWhrak9QUVFEQWdOSkFEQkcKQWlFQXRPQ0s4ZmdzZmxhaTczcXdXMkhQbWM2bDVXNmR2L1BzNGhHNDZFRkV0VlFDSVFDenFkQitkZnFiWkJ5cwpNUm0zbDU1N3pNOFBNcDhRUE5lVFdiM0VoOEdtVGc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlCZGpDQ0FSMmdBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwClpXNTBMV05oUURFM05UVTBPRE0yTnprd0hoY05NalV3T0RFNE1ESXlNVEU1V2hjTk16VXdPREUyTURJeU1URTUKV2pBak1TRXdId1lEVlFRRERCaHJNM010WTJ4cFpXNTBMV05oUURFM05UVTBPRE0yTnprd1dUQVRCZ2NxaGtqTwpQUUlCQmdncWhrak9QUU1CQndOQ0FBU3JxQzd2RUhKYzQzUThIWG5MT0VQeXkyM0tYZzlHOVkycTJUaVFLMGhoCkJvNnh1WUxDMTFSWkhGNC85NGZJZitZa3BCcmRpcFFNTjRSaVVrUGZzM28zbzBJd1FEQU9CZ05WSFE4QkFmOEUKQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVU0cWpIUk55MTVTS1U3V3d3WU94VgpLM3J1TGxBd0NnWUlLb1pJemowRUF3SURSd0F3UkFJZ041WmJQaEs4YkwxWllmcStGTVNNbkFCdEgzRSsxcnFoClpRUHY4UWM3S09nQ0lCMWhBclM5SXhKU1dYYlV3ZWE4WU0yVUNEMlplYTVxMHJMQnd4SHFqb3RjCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
KeyData: "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUpuM2dPd0lBNzJGMXE2dkhvMHdDRk1RS0VXVmVnejlQYy9NRFhVVDU5c3pvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFczN2NmZkWVByZ3p4TFFBNCtMQ0ZERlZEbUFvejVBMHk0TUN3bExsQlVTN2JhUHFtRmc5cgplc0JvcEplQjB3TzVYK2JoT09WSUdVSGVBZ0tCNmVtN3ZnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=",
},
{
Name: "cluster2",
Host: "https://10.6.80.12:6443",
Description: "Kubernetes Cluster 2",
CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWCtGQVJITzJWdVl3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHpOVEV3TWpnd016VXhOVE5hTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUROdFJSeG5JYVU2MS93UHVWNkpiR0hLaWtaZWVmYXlNOEFzVHRQeXQwaU5BaFgvVWNUT1pSVWYyZmUKTXBKSFNDdy9QQjJ2d1dCZDB2OVBEVWZ6RTYxL0lKcmhWZU54NmRxK0VPdVFqRmI2TlMvbkpiWmpXVFoyRFhBRQpkS1lwaGpXWGV3dWVuK0htTjlyK2tIZGlORVdmc0xDb1hWOFFMSmVRZXF4NHY2eTFkaEE1Ly9sdGxRV0ZsN2ZFCkRzeUpQb05tQmhzSy9SNEpYVDZ4Q0NqYmJmRFF6OE1hTXA0aWZnRW9ac0R6T2RlK3ZDL3diMEcxVmlpL1FjOEEKSCtSb2tJUkI2MTZqM0VjOWhsd1V4UjNyZThqOGFFdDJob1BkbTVhekt1YjQ0LzlKc3VaU1BWR0FYVXVjekQyawpYUU5UOWErOVl4RXZJZ0psdFpuRGVYSjZmeTFqQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSVEo2WWgwQ3lWVDRGNEhJUSszYWVhQzZzMUlUQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1pZM0xuUDl4Qgp1MjJaMENtazdiNUI2T1RtRS9obWlNRDNXY3kyb3RpcVhvZUE1VENRWnZxUk1PTk1NR3NCZFYza3FRRFhyaVR1CkQ4MDdaL3Q3SlAvOGo1RmRncDBCbkpoOUtlQkhaeVBybWFQNW9veFg4VWhFZHF0bWdsTUtBSk0xVmpKTExZNUwKMUcyRVNWa09NKytTSkV5MGJMbU9LM3M2YUI1L05pK3BVVS82Z1ZFNDFIZnh1SEJVYUtrRXNJR1d0WnNxbEY1cwp1RVAzZnY0ZmJRZVAxTmEvRlNaSmh4NlBybEdjZlE2Vmh6a1haY2Q1RExKMHZHbHZoTGdwREowdUVsUEd6NU5KCldFelVJZ3BGV25UMUd4TlhuNm02Sm9oMmNoWU5oQ25KOGZCS0Q4elozei9LdExCa2JwMDdMRlgwbzhXQUhEQmcKK1A4cjUwTm5IT3FHCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJWUlIcnhuOXYvOTR3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHlOakV3TXpBd016VXhOVE5hTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEd0NGWW0KY1JldG5xWjJBR21FUGJ2L1pRVzdrSzFKNHlBUmI2ODVlNEl5QjQ2OXdKOFVtd1crOXB2OWNsVm5YV3pnQkY3WQpnbkIyNi9DTWtqOVpnRkhOaWFPK3RXcXg3cHJKTkdDaHhiY29VMDZzQUIwR3MvUkVHK3VYMnFZa3RnVHpRNWFrCitGKzZrZElRek5VdnpwWFUzUFlHcDFEcGlzNWxZNFYzMkhnSkRaZkMrRzlpT1ROd1dtTzV3bGF1K1lsQkRGTVIKS2tnVFo1MDY5OXl5NWxnUlRoaTczSG1hUCtLWGdIT0QrNkNmeUZ6Ty80KzdLaExjanZpTGFUVjBjNGkzYkxidQo0K0llU2pwMEpxU2lxQlFtRHhHRitYMndCSkNiRVZObWJrd0hCVlh5eXlxdGJWV2dibEN6SWJ0UDBadHE3RUMwClo0WkNDemc5RFNqRGQwZWZBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkZNbnBpSFFMSlZQZ1hnYwpoRDdkcDVvTHF6VWhNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUFzTHJBMEhFOVNGNHAvSzBQejlVdFZLdk9rCjNUaEZ0ODZGTGlWNEJMcTZ5RSt1aHdHazk0b3p1Y3c1T2h1WEduTWFaUlFMYnliS3pJcjQvUUNqQVQ5eFVURWQKSFQ4c1c1UEhHMm5lbGJRckFNdVhRaFpXdlZTRmZ6Tk5GZG0rNStzdnVXajVtMklyNXNYRURlV2dBdmNLd3k2cwpVUjIxSmdtVXZHSFFtTVVZYWpnYW8wS3NjQmtNOEpZekFKdXZWdkJtTytwdzN5T2hVVmMyY0JnV0gybmx3L3RLCjZRR0Y0ZUZPRnJaYzM5UHp2NmlVOHFBYnNrQlVTVlhuaXg3dTNZUzFwTHNuZitSY0U0MmR1RzV4Nll3UFBlb28KRXBwWVluZ1R5TlpKKzVGaHVZdTUwMDJsQm1DV3JrSkxEek5NWlR3ai9DeG52ekVnSWJPWFpndnRpSXhpCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
KeyData: "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBOEFoV0puRVhyWjZtZGdCcGhEMjcvMlVGdTVDdFNlTWdFVyt2T1h1Q01nZU92Y0NmCkZKc0Z2dmFiL1hKVloxMXM0QVJlMklKd2R1dndqSkkvV1lCUnpZbWp2clZxc2U2YXlUUmdvY1czS0ZOT3JBQWQKQnJQMFJCdnJsOXFtSkxZRTgwT1dwUGhmdXBIU0VNelZMODZWMU56MkJxZFE2WXJPWldPRmQ5aDRDUTJYd3ZodgpZamt6Y0ZwanVjSldydm1KUVF4VEVTcElFMmVkT3ZmY3N1WllFVTRZdTl4NW1qL2lsNEJ6Zy91Z244aGN6ditQCnV5b1MzSTc0aTJrMWRIT0l0MnkyN3VQaUhrbzZkQ2Frb3FnVUpnOFJoZmw5c0FTUW14RlRabTVNQndWVjhzc3EKclcxVm9HNVFzeUc3VDlHYmF1eEF0R2VHUWdzNFBRMG93M2RIbndJREFRQUJBb0lCQUFxSWt4OUV2MEZEUVJMVQptY3pQMkx3d2RydndjV3BZcVVPYW54bnFyWi84Yk9zdTFNeFdzVDNjSEtSV3JDREpITW9INXhHaFI4WXdQSEl1CnlORG9ySzVVWi9jcWh2QWdCSExuOVlXajQ1SEZkaUplTHVmb1pjUEhaZU5ZR1FwclluUTZkeFh1UUdVem1RQmIKdk05SVJaTDl6MTRqWVkyZUpjaVZRWG9zNmJlYjUxYjgxNGljMTg1RHNtK2RhekRuNG14M2tNT0lueFR2K01pNQpxSWx5OU8vQURIaWpNd2taNVY5K3grSlpxM3Exc09SeTBKcUUwd1czbFcwQnFxSWRGRFRSelAvMFdiVGZZdDU3CmlRNjJySnhEN1RGNzR3Ni8xc3VqalU3Y2VsK1ltdTRvRFZjb05pOGdoTE1UZXE1OWpPMk1xR1FqMU5HUHRuTHkKb0hFOUs4RUNnWUVBOVRiQ3VEUlBtVDFmN0MwUldYUkJnejlENWhhRExkaS82aitjMGx5amR0TjkyR2JHdFNFMQozVVIvc2dsRit3bVliWmJmNExqUnpibnNZTGFleHRtakpzWXdFK0t4SSt3SEloSElPRFFaSTBaT08vMTJYdm1oCjB4dDdUNmNTVTZZSHZEbkp4WkpFaGt3TjBwL1ZoSHZMZFZMWmd3ZnNtQWlVekNTTVBmaUkySmtDZ1lFQStwYzcKTUJ0ZFNBZnd5cElMaUR6dis2WjFBQnVrWUphWnFQTk9IRGdLeElRNVJEQVZ5K3hSQXJWQ1V3RE5WdDJtTGJHUQpHZysvWXl4ZllEd2dSYTIxMUJDL0pUU3E4S1dHYVdXM0h2Z0VmMk54cVVIckNkT3VGZGhqdWkrMlRBdEdBb0w1CjluSGx3TXBZVVpydjF6dENCRmx4L1ZYd3NxUGZ6K2l5ZG1CVUxQY0NnWUVBcFM5Q2RMd29jdDQ1WSt2b0tBNTgKbzJGVzZBUjZVY1FWWkVOOTdPZWk1a1VLSFdEK3NyMndmMkhKYzdGemh1eXIxZ2N3d1QwL2VBcXJCV3VBQWd4UwpMNmlLY3ByZklZZTZObVVzTDFCSkxzNEpuYmZjcVpZWVFSSGVPNFljZm1UMkNRSVV2aGNPT2ptNWhnMU4xSFZnClZhUitDaHFvY3JJMUtsL2thVXFuUk9FQ2dZRUF5ZWx0RVhnYkUxMENrZFpYWUhEcFZUVnNkS2ZSTE5wcitZd0IKMWc3NTdobzBJbE0wWE5tTzlNV2tLVWt1S3QzeGRrUHFQbldOMnBUNFRJeGwzSDc1VVdRbEFBK041TlVhbG5ZVQp0T2xXaG1aVVFQTVNOUnJRM0YwOURkby80c242b1M5enhUVkUwTEM1dFJkSVJYNUQxVWxVNWJHSGZnazQzMGM1CjlOUHRQMFVDZ1lFQXk1L05hZXJlZDlQSDcyVzNDNW1UQy9jbEQxdUdmZXdPVkFkdko1eldlMDh4Q01CcEpya1QKU3dKM3NZOXYyaEdwSUxYZnU5YnppL0RWaW1sZk5MNkZBV2VaR3BCYm1qTHBEcUxWRzdhcUNHQVcvRG9iNmVlWApweEFiQTBLaUhoaE9sdUdONHdkbFdQRzNWdTlZNXZIb3RBNW1iZlRpaHhUYTlEZWRkZXlkNC9RPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=",
},
},
} }
} }
// 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

@ -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,28 +4,49 @@ 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
Name string WorkspaceID string // 所属 workspaceNULL 表示全局共享
Host string // Kubernetes API Server URL OwnerID string // 创建者用户 ID
CAData string // Base64 encoded CA certificate Name string
CertData string // Base64 encoded client certificate Host string // Kubernetes API Server URL
KeyData string // Base64 encoded client key CAData string // Base64 encoded CA certificate
Token string // Bearer token (alternative to cert auth) CertData string // Base64 encoded client certificate
Description string KeyData string // Base64 encoded client key
CreatedAt time.Time Token string // Bearer token (alternative to cert auth)
UpdatedAt time.Time Description string
// 隔离模式
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,
CreatedAt: now, Name: name,
UpdatedAt: now, Host: host,
IsolationMode: IsolationModeNamespace, // 默认 namespace 隔离模式
DefaultNamespace: workspaceID, // 默认使用 workspace ID 作为 namespace 前缀
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
} }
} }
@ -63,11 +84,35 @@ func (c *Cluster) Validate() error {
if c.Host == "" { if c.Host == "" {
return ErrInvalidClusterHost return ErrInvalidClusterHost
} }
// 必须有认证方式:证书或 Token
if (c.CertData == "" || c.KeyData == "") && c.Token == "" { // 检查是否有 kubeconfig 格式(完整的 kubeconfig 在 CAData 中)
return ErrInvalidClusterAuth 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
} }
return nil
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
}
// namespace 隔离模式
if c.DefaultNamespace != "" {
return c.DefaultNamespace + "-" + instanceName
}
return workspaceName + "-" + instanceName
} }
// GetKubeConfig 生成 kubeconfig 内容 // GetKubeConfig 生成 kubeconfig 内容

View File

@ -37,4 +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
ErrInvalidWorkspaceName = errors.New("invalid workspace name")
ErrWorkspaceNotFound = errors.New("workspace not found")
ErrWorkspaceExists = errors.New("workspace already exists")
// Quota errors
ErrQuotaExceeded = errors.New("quota exceeded")
ErrInvalidQuota = errors.New("invalid quota")
// Storage errors
ErrInvalidStorageName = errors.New("invalid storage name")
ErrStorageNotFound = errors.New("storage not found")
ErrStorageExists = errors.New("storage already exists")
// Chart Reference errors
ErrInvalidChartReferenceName = errors.New("invalid chart reference name")
ErrChartReferenceNotFound = errors.New("chart reference not found")
ErrChartReferenceExists = errors.New("chart reference already exists")
// Template errors
ErrInvalidTemplateName = errors.New("invalid template name")
ErrTemplateNotFound = errors.New("template not found")
ErrTemplateExists = errors.New("template already exists")
// Permission errors
ErrPermissionDenied = errors.New("permission denied")
) )

View File

@ -1,7 +1,12 @@
package entity package entity
import ( import (
"log"
"strings"
"time" "time"
"unicode"
"gopkg.in/yaml.v3"
) )
// InstanceStatus 实例状态 // InstanceStatus 实例状态
@ -33,43 +38,65 @@ const (
// Instance Helm 应用实例领域实体 // Instance Helm 应用实例领域实体
type Instance struct { type Instance struct {
ID string ID string
ClusterID string WorkspaceID string // 所属 workspace
Name string // Helm Release Name OwnerID string // 创建者用户 ID
Namespace string ClusterID string
RegistryID string RegistryID string
Repository string // OCI Repository (e.g., charts/app) ChartReferenceID string // 引用的 Chart 引用
Chart string // Chart Name ValuesTemplateID string // 使用的 Values 模板
Version string // Chart Version
Description string Name string // Helm Release Name
Values map[string]interface{} // Helm Values (JSON) Namespace string
ValuesYAML string // Helm Values (YAML format) Repository string // OCI Repository (e.g., charts/app)
Status InstanceStatus Chart string // Chart Name
StatusReason string Version string // Chart Version
LastOperation InstanceOperation Description string
LastError string Values map[string]interface{} // Helm Values (JSON)
Revision int // Helm Release Revision ValuesYAML string // Helm Values (YAML format)
CreatedAt time.Time UserOverrideYAML string // 用户额外覆盖的配置
UpdatedAt time.Time
Status InstanceStatus
StatusReason string
LastOperation InstanceOperation
LastError string
Revision int // Helm Release Revision
// 资源使用统计Helm 安装时从集群获取并更新)
CPURequested float64 // CPU 请求量 (cores)
MemoryRequested string // 内存请求量 (e.g., "2Gi")
GPURequested float64 // GPU 请求量 (cards)
GPUMemoryRequested string // GPU 内存请求量 (e.g., "16Gi")
CreatedAt time.Time
UpdatedAt time.Time
} }
// 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,
} }
} }
@ -79,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()
} }
@ -154,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

@ -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,24 +7,30 @@ import (
// Registry OCI Registry 领域实体 // Registry OCI Registry 领域实体
type Registry struct { type Registry struct {
ID string ID string
WorkspaceID string // 所属 workspaceNULL 表示全局共享
OwnerID string // 创建者用户 ID
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,
CreatedAt: now, Name: name,
UpdatedAt: now, URL: url,
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
} }
} }

View File

@ -0,0 +1,106 @@
package entity
import (
"encoding/json"
"time"
)
// StorageType 存储类型
type StorageType string
const (
StorageTypeNFS StorageType = "nfs"
StorageTypePV StorageType = "pv"
StorageTypeHostPath StorageType = "hostPath"
)
// StorageConfig 存储配置
type StorageConfig struct {
NFS *NFSConfig `json:"nfs,omitempty"`
PV *PVConfig `json:"pv,omitempty"`
HostPath *HostPathConfig `json:"hostPath,omitempty"`
}
// NFSConfig NFS 配置
type NFSConfig struct {
Server string `json:"server"`
Path string `json:"path"`
}
// PVConfig PV 配置
type PVConfig struct {
StorageClassName string `json:"storageClassName"`
Capacity string `json:"capacity"`
AccessModes []string `json:"accessModes"`
}
// HostPathConfig HostPath 配置
type HostPathConfig struct {
Path string `json:"path"`
}
// StorageBackend 存储后端实体
type StorageBackend struct {
ID string
WorkspaceID string
ClusterID string // 关联的 clusterNULL 表示 workspace/shared 级别
OwnerID string
Name string
Type StorageType
Config StorageConfig
Description string
IsDefault bool
IsShared bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewStorageBackend 创建新存储后端
func NewStorageBackend(workspaceID, ownerID, name string, storageType StorageType, config StorageConfig) *StorageBackend {
now := time.Now()
return &StorageBackend{
WorkspaceID: workspaceID,
OwnerID: ownerID,
Name: name,
Type: storageType,
Config: config,
IsDefault: false,
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
}
}
// NewClusterStorageBackend 创建 cluster 级别的存储后端
func NewClusterStorageBackend(workspaceID, clusterID, ownerID, name string, storageType StorageType, config StorageConfig) *StorageBackend {
storage := NewStorageBackend(workspaceID, ownerID, name, storageType, config)
storage.ClusterID = clusterID
return storage
}
// Validate 验证存储后端数据
func (s *StorageBackend) Validate() error {
if s.Name == "" {
return ErrInvalidStorageName
}
if s.Type == "" {
return ErrInvalidStorageName
}
return nil
}
// ConfigJSON 将配置转为 JSON 字符串
func (s *StorageBackend) ConfigJSON() (string, error) {
data, err := json.Marshal(s.Config)
if err != nil {
return "", err
}
return string(data), nil
}
// ParseConfigJSON 从 JSON 解析配置
func ParseConfigJSON(jsonStr string) (*StorageConfig, error) {
var config StorageConfig
err := json.Unmarshal([]byte(jsonStr), &config)
return &config, err
}

View File

@ -4,30 +4,58 @@ import (
"time" "time"
) )
// UserRole 用户角色
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleUser UserRole = "user"
)
// User 用户领域实体 // User 用户领域实体
type User struct { type User struct {
ID string ID string
Username string Username string
PasswordHash string PasswordHash string
Email string Email string
RevokedAfter time.Time // 全局 Token 撤销时间 Role UserRole // 用户角色: admin, user
CreatedAt time.Time WorkspaceID string // 所属工作空间admin 为空表示全局
UpdatedAt time.Time IsActive bool // 账户是否激活
MustChangePassword bool // 首次登录必须修改密码
RevokedAfter time.Time // 全局 Token 撤销时间
CreatedAt time.Time
UpdatedAt time.Time
} }
// NewUser 创建新用户 // NewUser 创建新用户
func NewUser(username, passwordHash, email string) *User { func NewUser(username, passwordHash, email string) *User {
now := time.Now() now := time.Now()
return &User{ return &User{
Username: username, Username: username,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Email: email, Email: email,
RevokedAfter: time.Unix(0, 0), // 初始值1970-01-01 Role: RoleUser, // 默认普通用户
CreatedAt: now, IsActive: true,
UpdatedAt: now, MustChangePassword: true, // 首次登录必须修改密码
RevokedAfter: time.Unix(0, 0), // 初始值1970-01-01
CreatedAt: now,
UpdatedAt: now,
} }
} }
// IsAdmin 判断是否为管理员
func (u *User) IsAdmin() bool {
return u.Role == RoleAdmin
}
// CanAccessWorkspace 检查是否可以访问指定工作空间
func (u *User) CanAccessWorkspace(workspaceID string) bool {
if u.IsAdmin() {
return true // Admin 可以访问所有工作空间
}
return u.WorkspaceID == workspaceID
}
// UpdatePassword 更新密码(会触发全局登出) // UpdatePassword 更新密码(会触发全局登出)
func (u *User) UpdatePassword(newPasswordHash string) { func (u *User) UpdatePassword(newPasswordHash string) {
u.PasswordHash = newPasswordHash u.PasswordHash = newPasswordHash

View File

@ -0,0 +1,83 @@
package entity
import (
"time"
)
// ValuesTemplate Values 模板实体(带版本管理)
type ValuesTemplate struct {
ID string
WorkspaceID string
OwnerID string
ChartReferenceID string
Name string
Description string
ValuesYAML string
Version int // 模板版本号
IsDefault bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewValuesTemplate 创建新 Values 模板
func NewValuesTemplate(workspaceID, ownerID, chartReferenceID, name, valuesYAML string) *ValuesTemplate {
now := time.Now()
return &ValuesTemplate{
WorkspaceID: workspaceID,
OwnerID: ownerID,
ChartReferenceID: chartReferenceID,
Name: name,
ValuesYAML: valuesYAML,
Version: 1,
IsDefault: false,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证 Values 模板数据
func (v *ValuesTemplate) Validate() error {
if v.Name == "" {
return ErrInvalidTemplateName
}
if v.ValuesYAML == "" {
return ErrInvalidTemplateName
}
return nil
}
// IncrementVersion 递增版本号
func (v *ValuesTemplate) IncrementVersion() {
v.Version++
v.UpdatedAt = time.Now()
}
// UserConfigOverride 用户配置覆盖实体
type UserConfigOverride struct {
ID string
WorkspaceID string
UserID string
TargetType string // 'storage', 'template', 'global'
TargetID string
Config map[string]interface{}
Priority int
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUserConfigOverride 创建新用户配置覆盖
func NewUserConfigOverride(workspaceID, userID, targetType, targetID string, config map[string]interface{}) *UserConfigOverride {
now := time.Now()
return &UserConfigOverride{
WorkspaceID: workspaceID,
UserID: userID,
TargetType: targetType,
TargetID: targetID,
Config: config,
Priority: 0,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
}

View File

@ -0,0 +1,37 @@
package entity
import (
"time"
)
// Workspace 工作空间实体
type Workspace struct {
ID string
Name string
Description string
ClusterIDs []string // 关联的集群 ID 列表
CreatedBy string // 创建者用户 ID
CreatedAt time.Time
UpdatedAt time.Time
}
// NewWorkspace 创建新工作空间
func NewWorkspace(name, description, createdBy string, clusterIDs []string) *Workspace {
now := time.Now()
return &Workspace{
Name: name,
Description: description,
ClusterIDs: clusterIDs,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证工作空间数据
func (w *Workspace) Validate() error {
if w.Name == "" {
return ErrInvalidWorkspaceName
}
return nil
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// AuditLogRepository 审计日志仓储接口
type AuditLogRepository interface {
// Create 创建审计日志
Create(ctx context.Context, log *entity.AuditLog) error
// GetByWorkspace 获取 workspace 的审计日志
GetByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error)
// GetByUser 获取用户的审计日志
GetByUser(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error)
// GetByResource 获取资源的审计日志
GetByResource(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error)
// List 列出审计日志(分页)
List(ctx context.Context, limit, offset int) ([]*entity.AuditLog, error)
// DeleteByWorkspace 删除 workspace 的审计日志
DeleteByWorkspace(ctx context.Context, workspaceID string) error
}

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// ChartReferenceRepository Chart 引用仓储接口
type ChartReferenceRepository interface {
// Create 创建 Chart 引用
Create(ctx context.Context, chartRef *entity.ChartReference) error
// GetByID 根据 ID 获取 Chart 引用
GetByID(ctx context.Context, id string) (*entity.ChartReference, error)
// GetByWorkspace 获取 workspace 的所有 Chart 引用
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error)
// GetByRegistry 获取 registry 的所有 Chart 引用
GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error)
// GetByName 根据名称获取 Chart 引用
GetByName(ctx context.Context, workspaceID, chartName string) (*entity.ChartReference, error)
// Update 更新 Chart 引用
Update(ctx context.Context, chartRef *entity.ChartReference) error
// Delete 删除 Chart 引用
Delete(ctx context.Context, id string) error
// List 列出所有 Chart 引用(管理员用)
List(ctx context.Context) ([]*entity.ChartReference, error)
}

View File

@ -9,20 +9,26 @@ import (
type ClusterRepository interface { type ClusterRepository interface {
// Create 创建集群 // Create 创建集群
Create(ctx context.Context, cluster *entity.Cluster) error Create(ctx context.Context, cluster *entity.Cluster) error
// GetByID 根据 ID 获取集群 // GetByID 根据 ID 获取集群
GetByID(ctx context.Context, id string) (*entity.Cluster, error) GetByID(ctx context.Context, id string) (*entity.Cluster, error)
// GetByName 根据名称获取集群 // GetByName 根据名称获取集群
GetByName(ctx context.Context, name string) (*entity.Cluster, error) GetByName(ctx context.Context, name string) (*entity.Cluster, error)
// Update 更新集群 // Update 更新集群
Update(ctx context.Context, cluster *entity.Cluster) error Update(ctx context.Context, cluster *entity.Cluster) error
// Delete 删除集群 // Delete 删除集群
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
// List 列出所有集群 // List 列出所有集群
List(ctx context.Context) ([]*entity.Cluster, error) List(ctx context.Context) ([]*entity.Cluster, error)
// GetByWorkspace 获取 workspace 的所有集群(包括共享集群)
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error)
// GetShared 获取所有共享集群
GetShared(ctx context.Context) ([]*entity.Cluster, error)
} }

View File

@ -9,23 +9,26 @@ import (
type InstanceRepository interface { type InstanceRepository interface {
// Create 创建实例 // Create 创建实例
Create(ctx context.Context, instance *entity.Instance) error Create(ctx context.Context, instance *entity.Instance) error
// GetByID 根据 ID 获取实例 // GetByID 根据 ID 获取实例
GetByID(ctx context.Context, id string) (*entity.Instance, error) GetByID(ctx context.Context, id string) (*entity.Instance, error)
// GetByClusterAndName 根据集群 ID 和名称获取实例 // GetByClusterAndName 根据集群 ID 和名称获取实例
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
// Update 更新实例 // Update 更新实例
Update(ctx context.Context, instance *entity.Instance) error Update(ctx context.Context, instance *entity.Instance) error
// Delete 删除实例 // Delete 删除实例
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
// ListByCluster 列出指定集群的所有实例 // ListByCluster 列出指定集群的所有实例
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
// List 列出所有实例 // List 列出所有实例
List(ctx context.Context) ([]*entity.Instance, error) List(ctx context.Context) ([]*entity.Instance, error)
// GetByWorkspace 列出指定工作空间的所有实例(用于配额检查)
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error)
} }

View File

@ -19,7 +19,10 @@ type OCIClient interface {
// GetValuesSchema 获取 Helm Chart 的 values schema // GetValuesSchema 获取 Helm Chart 的 values schema
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
// GetValues 获取 Helm Chart 的 values.yaml
GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
// PullArtifact 下载 artifact 到本地 // PullArtifact 下载 artifact 到本地
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error

View File

@ -0,0 +1,30 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// QuotaRepository 配额仓储接口
type QuotaRepository interface {
// Create 创建配额
Create(ctx context.Context, quota *entity.WorkspaceQuota) error
// GetByID 根据 ID 获取配额
GetByID(ctx context.Context, id string) (*entity.WorkspaceQuota, error)
// GetByWorkspaceAndType 根据 workspace 和资源类型获取配额
GetByWorkspaceAndType(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error)
// GetByWorkspace 获取 workspace 的所有配额
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error)
// Update 更新配额
Update(ctx context.Context, quota *entity.WorkspaceQuota) error
// Delete 删除配额
Delete(ctx context.Context, id string) error
// DeleteByWorkspace 删除 workspace 的所有配额
DeleteByWorkspace(ctx context.Context, workspaceID string) error
}

View File

@ -0,0 +1,42 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// StorageRepository 存储后端仓储接口
type StorageRepository interface {
// Create 创建存储后端
Create(ctx context.Context, storage *entity.StorageBackend) error
// GetByID 根据 ID 获取存储后端
GetByID(ctx context.Context, id string) (*entity.StorageBackend, error)
// GetByName 根据名称获取存储后端
GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error)
// GetByWorkspace 获取 workspace 的所有存储后端
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error)
// GetShared 获取所有共享存储后端
GetShared(ctx context.Context) ([]*entity.StorageBackend, error)
// GetDefault 获取 workspace 的默认存储后端
GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error)
// GetByCluster 获取 cluster 关联的存储后端列表
GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error)
// GetDefaultByCluster 获取 cluster 的默认存储后端
GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error)
// Update 更新存储后端
Update(ctx context.Context, storage *entity.StorageBackend) error
// Delete 删除存储后端
Delete(ctx context.Context, id string) error
// List 列出所有存储后端(管理员用)
List(ctx context.Context) ([]*entity.StorageBackend, error)
}

View File

@ -9,20 +9,26 @@ import (
type UserRepository interface { type UserRepository interface {
// Create 创建用户 // Create 创建用户
Create(ctx context.Context, user *entity.User) error Create(ctx context.Context, user *entity.User) error
// GetByID 根据 ID 获取用户 // GetByID 根据 ID 获取用户
GetByID(ctx context.Context, id string) (*entity.User, error) GetByID(ctx context.Context, id string) (*entity.User, error)
// GetByUsername 根据用户名获取用户 // GetByUsername 根据用户名获取用户
GetByUsername(ctx context.Context, username string) (*entity.User, error) GetByUsername(ctx context.Context, username string) (*entity.User, error)
// Update 更新用户 // Update 更新用户
Update(ctx context.Context, user *entity.User) error Update(ctx context.Context, user *entity.User) error
// Delete 删除用户 // Delete 删除用户
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
// List 列出所有用户 // List 列出所有用户
List(ctx context.Context) ([]*entity.User, error) List(ctx context.Context) ([]*entity.User, error)
// ListByWorkspace 列出指定 workspace 的用户
ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error)
// ListActive 仅列出活跃用户
ListActive(ctx context.Context) ([]*entity.User, error)
} }

View File

@ -0,0 +1,36 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// ValuesTemplateRepository Values 模板仓储接口
type ValuesTemplateRepository interface {
// Create 创建 Values 模板
Create(ctx context.Context, template *entity.ValuesTemplate) error
// GetByID 根据 ID 获取 Values 模板
GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error)
// GetByWorkspace 获取 workspace 的所有 Values 模板
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error)
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error)
// GetByName 根据名称获取 Values 模板
GetByName(ctx context.Context, workspaceID, chartRefID, name string) (*entity.ValuesTemplate, error)
// GetHistory 获取模板的版本历史
GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error)
// Update 更新 Values 模板(自动递增版本)
Update(ctx context.Context, template *entity.ValuesTemplate) error
// Delete 删除 Values 模板
Delete(ctx context.Context, id string) error
// List 列出所有 Values 模板(管理员用)
List(ctx context.Context) ([]*entity.ValuesTemplate, error)
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// WorkspaceRepository Workspace 仓储接口
type WorkspaceRepository interface {
// Create 创建 Workspace
Create(ctx context.Context, workspace *entity.Workspace) error
// GetByID 根据 ID 获取 Workspace
GetByID(ctx context.Context, id string) (*entity.Workspace, error)
// GetByName 根据名称获取 Workspace
GetByName(ctx context.Context, name string) (*entity.Workspace, error)
// Update 更新 Workspace
Update(ctx context.Context, workspace *entity.Workspace) error
// Delete 删除 Workspace
Delete(ctx context.Context, id string) error
// List 列出所有 Workspace
List(ctx context.Context) ([]*entity.Workspace, error)
}

View File

@ -68,6 +68,16 @@ func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repos
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference) return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
} }
// GetValues 获取 Helm Chart 的 values.yaml
func (s *ArtifactService) GetValues(ctx context.Context, registryID, repository, reference string) (string, error) {
registry, err := s.registryRepo.GetByID(ctx, registryID)
if err != nil {
return "", entity.ErrRegistryNotFound
}
return s.ociClient.GetValues(ctx, registry, repository, reference)
}
// PullArtifact 下载 artifact // PullArtifact 下载 artifact
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error { func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
registry, err := s.registryRepo.GetByID(ctx, registryID) registry, err := s.registryRepo.GetByID(ctx, registryID)

View File

@ -0,0 +1,71 @@
package service
import (
"context"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// AuditService 审计日志领域服务
type AuditService struct {
auditLogRepo repository.AuditLogRepository
}
// NewAuditService 创建审计服务
func NewAuditService(auditLogRepo repository.AuditLogRepository) *AuditService {
return &AuditService{
auditLogRepo: auditLogRepo,
}
}
// Log 创建审计日志
func (s *AuditService) Log(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceID, resourceName string, details map[string]interface{}, ipAddress, userAgent string) error {
auditLog := &entity.AuditLog{
ID: uuid.New().String(),
WorkspaceID: workspaceID,
UserID: userID,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
ResourceName: resourceName,
Details: details,
IPAddress: ipAddress,
UserAgent: userAgent,
CreatedAt: time.Now(),
}
return s.auditLogRepo.Create(ctx, auditLog)
}
// LogAction 简化版日志记录
func (s *AuditService) LogAction(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceName string) error {
return s.Log(ctx, workspaceID, userID, action, resourceType, "", resourceName, nil, "", "")
}
// LogWithDetails 带详情的日志记录
func (s *AuditService) LogWithDetails(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceID, resourceName string, details map[string]interface{}) error {
return s.Log(ctx, workspaceID, userID, action, resourceType, resourceID, resourceName, details, "", "")
}
// GetLogs 获取审计日志
func (s *AuditService) GetLogs(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByWorkspace(ctx, workspaceID, limit)
}
// GetUserLogs 获取用户的审计日志
func (s *AuditService) GetUserLogs(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByUser(ctx, userID, limit)
}
// GetResourceLogs 获取资源的审计日志
func (s *AuditService) GetResourceLogs(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByResource(ctx, resourceType, resourceID, limit)
}
// GetAllLogs 获取所有审计日志Admin
func (s *AuditService) GetAllLogs(ctx context.Context, limit int, offset int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.List(ctx, limit, offset)
}

View File

@ -22,9 +22,9 @@ type PasswordHasher interface {
// TokenGenerator Token 生成器接口 // TokenGenerator Token 生成器接口
type TokenGenerator interface { type TokenGenerator interface {
Generate(userID, username string) (accessToken, refreshToken string, err error) Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
Verify(token string) (userID, username string, err error) Verify(token string) (userID, username, role, workspaceID string, err error)
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error) VerifyWithIssuedAt(token string) (userID, username, role, workspaceID string, issuedAt int64, err error)
Refresh(refreshToken string) (newAccessToken string, err error) Refresh(refreshToken string) (newAccessToken string, err error)
} }
@ -86,8 +86,8 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (acc
return "", "", entity.ErrInvalidPassword return "", "", entity.ErrInvalidPassword
} }
// 生成 Token // 生成 Token (包含 role 和 workspace_id)
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username) accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, string(user.Role), user.WorkspaceID)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@ -108,7 +108,7 @@ func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User,
// VerifyAccessToken 验证 Access Token包括 revoked_after 检查) // VerifyAccessToken 验证 Access Token包括 revoked_after 检查)
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) { func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
// 1. JWT 自验证 // 1. JWT 自验证
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token) userID, username, _, _, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }

View File

@ -0,0 +1,137 @@
package service
import (
"context"
"errors"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
var (
ErrChartReferenceNotFound = errors.New("chart reference not found")
ErrChartReferenceExists = errors.New("chart reference already exists")
)
// ChartReferenceService Chart 引用领域服务
type ChartReferenceService struct {
chartRefRepo repository.ChartReferenceRepository
registryRepo repository.RegistryRepository
}
// NewChartReferenceService 创建 Chart 引用服务
func NewChartReferenceService(
chartRefRepo repository.ChartReferenceRepository,
registryRepo repository.RegistryRepository,
) *ChartReferenceService {
return &ChartReferenceService{
chartRefRepo: chartRefRepo,
registryRepo: registryRepo,
}
}
// Create 创建 Chart 引用
func (s *ChartReferenceService) Create(
ctx context.Context,
workspaceID, registryID, repository, chartName, description string,
) (*entity.ChartReference, error) {
// 检查 Registry 是否存在
registry, err := s.registryRepo.GetByID(ctx, registryID)
if err != nil {
return nil, errors.New("registry not found")
}
// 检查名称是否已存在
existing, _ := s.chartRefRepo.GetByName(ctx, workspaceID, chartName)
if existing != nil {
return nil, ErrChartReferenceExists
}
chartRef := entity.NewChartReference(workspaceID, registry.ID, repository, chartName, description)
chartRef.Description = description
if err := s.chartRefRepo.Create(ctx, chartRef); err != nil {
return nil, err
}
return chartRef, nil
}
// GetByID 获取 Chart 引用
func (s *ChartReferenceService) GetByID(ctx context.Context, id string) (*entity.ChartReference, error) {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrChartReferenceNotFound
}
return chartRef, nil
}
// GetByWorkspace 获取工作空间的所有 Chart 引用
func (s *ChartReferenceService) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error) {
return s.chartRefRepo.GetByWorkspace(ctx, workspaceID)
}
// GetByRegistry 获取 Registry 的所有 Chart 引用
func (s *ChartReferenceService) GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error) {
return s.chartRefRepo.GetByRegistry(ctx, registryID)
}
// Update 更新 Chart 引用
func (s *ChartReferenceService) Update(
ctx context.Context,
id, registryID, repository, chartName, description string,
isEnabled bool,
) (*entity.ChartReference, error) {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrChartReferenceNotFound
}
if registryID != "" {
chartRef.RegistryID = registryID
}
if repository != "" {
chartRef.Repository = repository
}
if chartName != "" {
chartRef.ChartName = chartName
}
chartRef.Description = description
chartRef.IsEnabled = isEnabled
if err := s.chartRefRepo.Update(ctx, chartRef); err != nil {
return nil, err
}
return chartRef, nil
}
// Delete 删除 Chart 引用
func (s *ChartReferenceService) Delete(ctx context.Context, id string) error {
return s.chartRefRepo.Delete(ctx, id)
}
// List 列出所有 Chart 引用(管理员用)
func (s *ChartReferenceService) List(ctx context.Context) ([]*entity.ChartReference, error) {
return s.chartRefRepo.List(ctx)
}
// Enable 启用 Chart 引用
func (s *ChartReferenceService) Enable(ctx context.Context, id string) error {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return ErrChartReferenceNotFound
}
chartRef.IsEnabled = true
return s.chartRefRepo.Update(ctx, chartRef)
}
// Disable 禁用 Chart 引用
func (s *ChartReferenceService) Disable(ctx context.Context, id string) error {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return ErrChartReferenceNotFound
}
chartRef.IsEnabled = false
return s.chartRefRepo.Update(ctx, chartRef)
}

View File

@ -2,9 +2,16 @@ package service
import ( import (
"context" "context"
"encoding/base64"
"fmt"
"os"
"github.com/google/uuid" "github.com/google/uuid"
"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"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
) )
// ClusterService 集群管理领域服务 // ClusterService 集群管理领域服务
@ -75,3 +82,105 @@ func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, e
return s.clusterRepo.List(ctx) return s.clusterRepo.List(ctx)
} }
// ListByWorkspace 列出指定 workspace 的集群(包括共享集群)
func (s *ClusterService) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
return s.clusterRepo.GetByWorkspace(ctx, workspaceID)
}
// GetSharedClusters 获取所有共享集群
func (s *ClusterService) GetSharedClusters(ctx context.Context) ([]*entity.Cluster, error) {
return s.clusterRepo.GetShared(ctx)
}
// TestConnection 测试集群连接是否可用
func (s *ClusterService) TestConnection(ctx context.Context, cluster *entity.Cluster) error {
// Mock 模式直接返回成功
if os.Getenv("ADAPTER_MODE") == "mock" {
return nil
}
// 尝试创建 k8s client
config, err := s.createRestConfig(cluster)
if err != nil {
return fmt.Errorf("failed to create k8s config: %w", err)
}
// 设置超时
config.Timeout = 30 * 1000000000 // 30秒 (nanoseconds)
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return fmt.Errorf("failed to create k8s client: %w", err)
}
// 测试连接 - 获取 version 信息
version, err := clientset.ServerVersion()
if err != nil {
return fmt.Errorf("failed to connect to cluster: %w", err)
}
if version == nil {
return fmt.Errorf("cluster returned nil version")
}
return nil
}
// createRestConfig 从 cluster 实体创建 k8s REST 配置
func (s *ClusterService) createRestConfig(cluster *entity.Cluster) (*rest.Config, error) {
// 优先使用 kubeconfig 格式(如果 CAData 包含完整的 kubeconfig 内容)
if len(cluster.CAData) > 100 && (cluster.CAData[:11] == "apiVersion:" || cluster.CAData[:5] == "kind:") {
return clientcmd.RESTConfigFromKubeConfig([]byte(cluster.CAData))
}
// 使用证书或 token 认证
config := &rest.Config{
Host: cluster.Host,
}
if cluster.CertData != "" && cluster.KeyData != "" {
// 尝试解码 base64 编码的证书,如果失败则尝试原始 PEM
var caData, certData, keyData []byte
var decodeErr error
// 先尝试 base64 解码
caData, decodeErr = base64.StdEncoding.DecodeString(cluster.CAData)
if decodeErr != nil {
// base64 解码失败,可能是原始 PEM
caData = []byte(cluster.CAData)
}
certData, decodeErr = base64.StdEncoding.DecodeString(cluster.CertData)
if decodeErr != nil {
certData = []byte(cluster.CertData)
}
keyData, decodeErr = base64.StdEncoding.DecodeString(cluster.KeyData)
if decodeErr != nil {
keyData = []byte(cluster.KeyData)
}
config.TLSClientConfig = rest.TLSClientConfig{
CAData: caData,
CertData: certData,
KeyData: keyData,
Insecure: false,
}
} else if cluster.Token != "" {
config.BearerToken = cluster.Token
} else {
// 尝试使用本地 kubeconfig
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
kubeconfig = ".kube/config"
}
// 尝试从文件加载 kubeconfig
if _, err := os.Stat(kubeconfig); err != nil {
return nil, fmt.Errorf("no valid credentials found for cluster %s (no cert/key/token, and kubeconfig file not found: %s)", cluster.Name, kubeconfig)
}
return clientcmd.BuildConfigFromFlags("", kubeconfig)
}
return config, nil
}

View File

@ -4,8 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -15,12 +17,13 @@ import (
// InstanceService Helm 实例管理领域服务 // InstanceService Helm 实例管理领域服务
type InstanceService struct { type InstanceService struct {
instanceRepo repository.InstanceRepository instanceRepo repository.InstanceRepository
clusterRepo repository.ClusterRepository clusterRepo repository.ClusterRepository
registryRepo repository.RegistryRepository registryRepo repository.RegistryRepository
helmClient repository.HelmClient helmClient repository.HelmClient
ociClient repository.OCIClient ociClient repository.OCIClient
entryClient repository.InstanceEntryClient entryClient repository.InstanceEntryClient
storageService *StorageService // for layered storage config resolution
} }
// NewInstanceService 创建实例服务 // NewInstanceService 创建实例服务
@ -33,15 +36,21 @@ func NewInstanceService(
entryClient repository.InstanceEntryClient, entryClient repository.InstanceEntryClient,
) *InstanceService { ) *InstanceService {
return &InstanceService{ return &InstanceService{
instanceRepo: instanceRepo, instanceRepo: instanceRepo,
clusterRepo: clusterRepo, clusterRepo: clusterRepo,
registryRepo: registryRepo, registryRepo: registryRepo,
helmClient: helmClient, helmClient: helmClient,
ociClient: ociClient, ociClient: ociClient,
entryClient: entryClient, entryClient: entryClient,
storageService: nil, // set via SetStorageService for layered storage
} }
} }
// SetStorageService 设置存储服务(用于分层存储配置解析)
func (s *InstanceService) SetStorageService(storageService *StorageService) {
s.storageService = storageService
}
const chartCacheDir = "/tmp/charts" const chartCacheDir = "/tmp/charts"
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string { func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
@ -88,6 +97,20 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
return entity.ErrInstanceExists return entity.ErrInstanceExists
} }
// ===== 分层存储配置解析 =====
// Priority: workspace-level default > cluster-level default > shared default
if s.storageService != nil && instance.WorkspaceID != "" {
resolution, err := s.storageService.ResolveStorageConfig(ctx, instance.ClusterID, instance.WorkspaceID)
if err == nil && resolution != nil && resolution.Storage != nil {
// Merge resolved storage values into instance.Values
if instance.Values == nil {
instance.Values = make(map[string]interface{})
}
// User override takes highest priority (already set), so we only set if not already present
mergeStorageToValues(instance.Values, resolution.Storage)
}
}
instance.BeginOperation(entity.OperationInstall, "Preparing installation") instance.BeginOperation(entity.OperationInstall, "Preparing installation")
// 先写入数据库,记录 pending 状态 // 先写入数据库,记录 pending 状态
@ -103,7 +126,16 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
} }
// 异步执行 Helm 安装并监控状态 // 异步执行 Helm 安装并监控状态
go s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance) go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[goroutine-panic] instanceID=%s panic=%v", instance.ID, r)
}
}()
log.Printf("[goroutine-start] instanceID=%s name=%s cluster=%s", instance.ID, instance.Name, cluster.Name)
s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance)
log.Printf("[goroutine-done] instanceID=%s", instance.ID)
}()
// 立即返回,状态同步由后台任务处理 // 立即返回,状态同步由后台任务处理
return nil return nil
@ -285,8 +317,10 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in
// executeAndSyncInstall 异步执行安装并监控状态 // executeAndSyncInstall 异步执行安装并监控状态
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) { func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
log.Printf("[install-start] instanceID=%s values=%v", instanceID, instance.Values)
// 执行 Helm 安装 // 执行 Helm 安装
if err := s.helmClient.Install(ctx, cluster, instance); err != nil { if err := s.helmClient.Install(ctx, cluster, instance); err != nil {
log.Printf("[install-fail] instanceID=%s err=%v", instanceID, err)
// 更新实例状态为失败 // 更新实例状态为失败
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID) instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
if updateErr == nil && instance != nil { if updateErr == nil && instance != nil {
@ -295,6 +329,7 @@ func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID
} }
return return
} }
log.Printf("[install-ok] instanceID=%s revision=%d", instanceID, instance.Revision)
// 安装成功后,同步状态 // 安装成功后,同步状态
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall) s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall)
@ -336,9 +371,17 @@ func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID
// executeAndSyncUninstall 异步执行卸载并监控状态 // executeAndSyncUninstall 异步执行卸载并监控状态
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) { func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
// 先验证 release 名称是否有效
// 如果名称无效,说明这个 release 根本不可能存在于 Helm 中,直接删除数据库记录
if err := entity.ValidateReleaseName(releaseName); err != nil {
// Release 名称无效,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID)
return
}
// 执行 Helm 卸载 // 执行 Helm 卸载
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace) err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
// 获取实例 // 获取实例
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID) instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
if getErr != nil { if getErr != nil {
@ -346,13 +389,22 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
} }
if err != nil { if err != nil {
// 如果错误不是"未找到",则标记为失败 // 检查错误类型
if !errors.Is(err, entity.ErrInstanceNotFound) { if errors.Is(err, entity.ErrInstanceNotFound) {
instance.MarkFailure("Helm uninstall failed", err) // 未找到,说明已经卸载,直接删除数据库记录
_ = s.instanceRepo.Update(ctx, instance)
} else {
// 如果未找到,说明已经卸载,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID) _ = s.instanceRepo.Delete(ctx, instanceID)
} else {
// 检查是否是 release 名称无效的错误(可能在某些情况下 Helm 会返回这个错误)
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "release name is invalid") ||
(strings.Contains(errMsg, "invalid") && strings.Contains(errMsg, "release")) {
// Release 名称无效,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID)
} else {
// 其他错误,标记为失败
instance.MarkFailure("Helm uninstall failed", err)
_ = s.instanceRepo.Update(ctx, instance)
}
} }
return return
} }
@ -360,7 +412,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
// 卸载成功,标记为已卸载 // 卸载成功,标记为已卸载
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully") instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
_ = s.instanceRepo.Update(ctx, instance) _ = s.instanceRepo.Update(ctx, instance)
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载 // 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace) _, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
@ -454,3 +506,48 @@ func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID str
_ = s.instanceRepo.Update(ctx, instance) _ = s.instanceRepo.Update(ctx, instance)
} }
} }
// mergeStorageToValues 将存储配置 merge 到 Helm values
// 只覆盖 nil/空的字段,保留用户已设置的 values
func mergeStorageToValues(values map[string]interface{}, storage *entity.StorageBackend) {
if storage == nil || values == nil {
return
}
persistence := make(map[string]interface{})
switch storage.Type {
case entity.StorageTypeNFS:
if storage.Config.NFS != nil {
persistence["type"] = "nfs"
persistence["nfs"] = map[string]interface{}{
"server": storage.Config.NFS.Server,
"path": storage.Config.NFS.Path,
}
// Helm common chart labels
persistence["mountOptions"] = []string{"rw", "relatime", "vers=3"}
persistence["reclaimPolicy"] = "Retain"
}
case entity.StorageTypePV:
if storage.Config.PV != nil {
persistence["type"] = "persistentVolumeClaim"
persistence["storageClass"] = storage.Config.PV.StorageClassName
persistence["size"] = storage.Config.PV.Capacity
persistence["accessMode"] = storage.Config.PV.AccessModes
}
case entity.StorageTypeHostPath:
if storage.Config.HostPath != nil {
persistence["type"] = "hostPath"
persistence["hostPath"] = map[string]interface{}{
"path": storage.Config.HostPath.Path,
}
}
}
// Only merge if key doesn't already exist and has a value
for key, val := range persistence {
if _, exists := values[key]; !exists && val != nil {
values[key] = val
}
}
}

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