ocdp v1
This commit is contained in:
79
.github/PROJECT_STRUCTURE.md
vendored
Normal file
79
.github/PROJECT_STRUCTURE.md
vendored
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## 📁 Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ocdp-go/
|
||||||
|
├── backend/ # Go 后端
|
||||||
|
│ ├── cmd/api/ # 主程序入口
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── handlers/ # HTTP handlers
|
||||||
|
│ │ ├── graphql/ # GraphQL schema & resolvers
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ ├── storage/ # 存储层(JSON)
|
||||||
|
│ │ └── auth/ # 认证
|
||||||
|
│ └── data/ # 数据存储(JSON文件)
|
||||||
|
│
|
||||||
|
└── frontend/ # React 前端
|
||||||
|
└── src/
|
||||||
|
├── app/ # 应用层
|
||||||
|
│ ├── providers/ # 全局 Provider
|
||||||
|
│ ├── routes/ # 路由配置
|
||||||
|
│ └── constants/ # 导航配置
|
||||||
|
│
|
||||||
|
├── features/ # 功能模块(按类别)
|
||||||
|
│ ├── configuration/ # 🔧 配置类
|
||||||
|
│ │ ├── clusters/
|
||||||
|
│ │ └── registries/
|
||||||
|
│ ├── monitoring/ # 📊 监控类
|
||||||
|
│ │ └── clusters/
|
||||||
|
│ ├── artifact/ # 📦 制品类
|
||||||
|
│ │ ├── registries/
|
||||||
|
│ │ └── instances/
|
||||||
|
│ ├── auth/
|
||||||
|
│ └── home/
|
||||||
|
│
|
||||||
|
├── core/ # 核心层
|
||||||
|
│ ├── api/ # API 调用
|
||||||
|
│ ├── graphql/ # GraphQL 客户端
|
||||||
|
│ ├── types/ # 类型定义
|
||||||
|
│ └── config/ # 配置
|
||||||
|
│
|
||||||
|
└── shared/ # 共享层
|
||||||
|
├── components/ # 通用组件
|
||||||
|
├── hooks/ # 通用 hooks
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
└── services/ # 服务
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Features 模块分类
|
||||||
|
|
||||||
|
### Configuration(配置类)
|
||||||
|
- `clusters/` - 配置 K8s 集群连接
|
||||||
|
- `registries/` - 配置 OCI 仓库连接
|
||||||
|
|
||||||
|
### Monitoring(监控类)
|
||||||
|
- `clusters/` - 监控集群状态
|
||||||
|
|
||||||
|
### Artifact(制品类)
|
||||||
|
- `registries/` - 浏览制品仓库内容
|
||||||
|
- `instances/` - 管理已部署的服务实例
|
||||||
|
|
||||||
|
## 🔄 API 架构
|
||||||
|
|
||||||
|
- **GraphQL** - 用于 CRUD 操作(集群、仓库、实例)
|
||||||
|
- **RESTful** - 用于代理和实时查询(OCI、K8s 状态)
|
||||||
|
- **Unified API** - 自动根据配置切换 GraphQL/RESTful
|
||||||
|
|
||||||
|
## 🚀 实例管理术语
|
||||||
|
|
||||||
|
- **Launch** - 启动/创建服务实例
|
||||||
|
- **Modify** - 修改实例配置
|
||||||
|
- **Terminate** - 终止实例运行
|
||||||
|
|
||||||
|
## 📝 说明
|
||||||
|
|
||||||
|
- 所有功能模块按**业务类别**分类(配置、监控、制品)
|
||||||
|
- Features 模块名使用**单数**形式(configuration, artifact)
|
||||||
|
- URL 路径保持 RESTful 风格(`/artifact/registries`)
|
||||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
backend/bin/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
backend/logs/
|
||||||
|
backend/*.log
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Data directories
|
||||||
|
backend/data/
|
||||||
|
backend/config/bootstrap.json
|
||||||
|
backend/config/bootstrap.prod.json
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
postgres_data/
|
||||||
|
redis_data/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
543
COMMANDS_CHEATSHEET.md
Normal file
543
COMMANDS_CHEATSHEET.md
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
# OCDP 命令速查表
|
||||||
|
|
||||||
|
快速查找常用的 Docker 和 Make 命令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境(推荐,Mock 模式)
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 生产环境(真实数据库)
|
||||||
|
make docker-prod
|
||||||
|
|
||||||
|
# 测试后端(独立)
|
||||||
|
make docker-test-backend
|
||||||
|
|
||||||
|
# 测试前端(独立)
|
||||||
|
make docker-test-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 完整命令列表
|
||||||
|
|
||||||
|
### Docker 服务管理
|
||||||
|
|
||||||
|
| 命令 | 说明 | 模式 |
|
||||||
|
|------|------|------|
|
||||||
|
| `make docker-dev` | 启动开发环境(前台) | Dev |
|
||||||
|
| `make docker-dev-bg` | 启动开发环境(后台) | Dev |
|
||||||
|
| `make docker-prod` | 启动生产环境 | Production |
|
||||||
|
| `make docker-up` | 同 docker-prod | Production |
|
||||||
|
| `make docker-down` | 停止所有服务 | - |
|
||||||
|
| `make docker-down-v` | 停止并删除数据卷 | - |
|
||||||
|
|
||||||
|
### 服务测试
|
||||||
|
|
||||||
|
| 命令 | 说明 | 端口 |
|
||||||
|
|------|------|------|
|
||||||
|
| `make docker-test-backend` | 测试后端(前台) | 8080 |
|
||||||
|
| `make docker-test-backend-bg` | 测试后端(后台) | 8080 |
|
||||||
|
| `make docker-test-frontend` | 测试前端(前台) | 3000 |
|
||||||
|
| `make docker-test-frontend-bg` | 测试前端(后台) | 3000 |
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `make docker-logs` | 查看所有服务日志 |
|
||||||
|
| `make docker-logs-backend` | 只看后端日志 |
|
||||||
|
| `make docker-logs-frontend` | 只看前端日志 |
|
||||||
|
| `make docker-logs-db` | 只看数据库日志 |
|
||||||
|
|
||||||
|
### 服务状态
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `make docker-ps` | 查看服务状态 |
|
||||||
|
| `make docker-status` | 查看详细状态(含健康检查) |
|
||||||
|
|
||||||
|
### 镜像管理
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `make docker-build` | 构建所有镜像 |
|
||||||
|
| `make docker-build-no-cache` | 无缓存构建 |
|
||||||
|
| `make docker-restart` | 重启所有服务 |
|
||||||
|
| `make docker-restart-backend` | 只重启后端 |
|
||||||
|
| `make docker-restart-frontend` | 只重启前端 |
|
||||||
|
|
||||||
|
### 工具服务
|
||||||
|
|
||||||
|
| 命令 | 说明 | 端口 |
|
||||||
|
|------|------|------|
|
||||||
|
| `make docker-tools` | 启动 pgAdmin + Swagger UI | 5050, 8081 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 原始 Docker Compose 命令
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动(前台)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# 启动(后台)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动(前台)
|
||||||
|
docker compose up
|
||||||
|
|
||||||
|
# 启动(后台)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock 模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试后端
|
||||||
|
docker compose -f docker-compose.mock.yml up backend
|
||||||
|
|
||||||
|
# 测试前端
|
||||||
|
docker compose -f docker-compose.mock.yml up frontend
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker compose -f docker-compose.mock.yml up -d backend
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker compose -f docker-compose.mock.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 本地开发命令(不使用 Docker)
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # 安装所有依赖
|
||||||
|
make install-tools # 安装 OpenAPI 工具
|
||||||
|
make install-deps # 安装项目依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-local # 启动完整环境
|
||||||
|
make dev-backend-mock # 只启动后端(Mock)
|
||||||
|
make dev-backend-prod # 只启动后端(生产)
|
||||||
|
make dev-frontend # 只启动前端
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAPI 工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make openapi-validate # 验证 OpenAPI 规范
|
||||||
|
make openapi-gen # 生成前后端代码
|
||||||
|
make openapi-gen-backend # 只生成后端代码
|
||||||
|
make openapi-gen-frontend # 只生成前端代码
|
||||||
|
make openapi-docs # 生成 HTML 文档
|
||||||
|
make openapi-view # 启动文档服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # 运行所有测试
|
||||||
|
make test-backend # 运行后端测试
|
||||||
|
make test-frontend # 运行前端测试
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make clean # 清理构建产物
|
||||||
|
make clean-generated # 清理生成的代码
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 常用 Docker 命令
|
||||||
|
|
||||||
|
### 容器操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入容器
|
||||||
|
docker compose exec backend sh
|
||||||
|
docker compose exec frontend sh
|
||||||
|
docker compose exec postgres psql -U postgres -d ocdp
|
||||||
|
|
||||||
|
# 查看容器列表
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 查看容器详情
|
||||||
|
docker compose ps -a
|
||||||
|
|
||||||
|
# 删除容器
|
||||||
|
docker compose rm backend
|
||||||
|
docker compose rm -f backend # 强制删除
|
||||||
|
|
||||||
|
# 停止特定容器
|
||||||
|
docker compose stop backend
|
||||||
|
docker compose stop frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 实时日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 只看特定服务
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
# 显示最后 N 行
|
||||||
|
docker compose logs --tail=100 backend
|
||||||
|
|
||||||
|
# 显示时间戳
|
||||||
|
docker compose logs -f -t backend
|
||||||
|
|
||||||
|
# 不跟随(只显示现有)
|
||||||
|
docker compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 镜像操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# 无缓存构建
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
# 只构建特定服务
|
||||||
|
docker compose build backend
|
||||||
|
docker compose build frontend
|
||||||
|
|
||||||
|
# 查看镜像
|
||||||
|
docker images | grep ocdp
|
||||||
|
|
||||||
|
# 删除镜像
|
||||||
|
docker rmi ocdp-backend
|
||||||
|
docker rmi ocdp-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据卷操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看数据卷
|
||||||
|
docker volume ls
|
||||||
|
|
||||||
|
# 删除特定数据卷
|
||||||
|
docker volume rm ocdp_postgres_data
|
||||||
|
docker volume rm ocdp_redis_data
|
||||||
|
|
||||||
|
# 删除所有未使用的数据卷
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# 备份数据卷
|
||||||
|
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz -C /data .
|
||||||
|
|
||||||
|
# 恢复数据卷
|
||||||
|
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine sh -c "cd /data && tar xzf /backup/postgres_backup.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网络操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看网络
|
||||||
|
docker network ls
|
||||||
|
|
||||||
|
# 查看网络详情
|
||||||
|
docker network inspect ocdp-network
|
||||||
|
|
||||||
|
# 测试网络连通性
|
||||||
|
docker compose exec backend ping frontend
|
||||||
|
docker compose exec frontend ping backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 调试命令
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端健康检查
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# 前端健康检查
|
||||||
|
curl http://localhost:5173/ # Dev 模式
|
||||||
|
curl http://localhost:3000/ # Production 模式
|
||||||
|
|
||||||
|
# 数据库健康检查
|
||||||
|
docker compose exec postgres pg_isready -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123"}'
|
||||||
|
|
||||||
|
# 获取 Registries
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
|
||||||
|
# 获取 Clusters
|
||||||
|
curl http://localhost:8080/api/v1/clusters
|
||||||
|
|
||||||
|
# 获取 Repositories
|
||||||
|
curl http://localhost:8080/api/v1/registries/harbor-prod/repositories
|
||||||
|
|
||||||
|
# 获取 Artifacts(带过滤)
|
||||||
|
curl "http://localhost:8080/api/v1/registries/harbor-prod/repositories/charts%2Fvllm-serve/artifacts?media_type=chart"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接数据库
|
||||||
|
docker compose exec postgres psql -U postgres -d ocdp
|
||||||
|
|
||||||
|
# 查看表
|
||||||
|
docker compose exec postgres psql -U postgres -d ocdp -c "\dt"
|
||||||
|
|
||||||
|
# 查询数据
|
||||||
|
docker compose exec postgres psql -U postgres -d ocdp -c "SELECT * FROM registries;"
|
||||||
|
|
||||||
|
# 执行 SQL 文件
|
||||||
|
docker compose exec -T postgres psql -U postgres -d ocdp < schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能监控
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看资源使用
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# 只看特定容器
|
||||||
|
docker stats ocdp-backend ocdp-frontend
|
||||||
|
|
||||||
|
# 查看容器进程
|
||||||
|
docker compose top backend
|
||||||
|
docker compose top frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 常用组合命令
|
||||||
|
|
||||||
|
### 完全重置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止并删除所有内容
|
||||||
|
docker compose down -v
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
docker compose -f docker-compose.mock.yml down -v
|
||||||
|
|
||||||
|
# 删除镜像
|
||||||
|
docker rmi $(docker images | grep ocdp | awk '{print $3}')
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
make docker-build
|
||||||
|
|
||||||
|
# 重新启动
|
||||||
|
make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新代码后重新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止服务
|
||||||
|
make docker-down
|
||||||
|
|
||||||
|
# 拉取最新代码
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
make docker-build
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看完整系统状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有容器
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 查看所有日志
|
||||||
|
make docker-logs
|
||||||
|
|
||||||
|
# 查看健康状态
|
||||||
|
make docker-status
|
||||||
|
|
||||||
|
# 测试所有服务
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
curl http://localhost:5173/
|
||||||
|
curl http://localhost:5432 # 数据库端口
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 环境变量
|
||||||
|
|
||||||
|
### 后端环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mock 模式
|
||||||
|
export ADAPTER_MODE=mock
|
||||||
|
export JWT_SECRET=dev-secret
|
||||||
|
export ENCRYPTION_KEY=dev-encryption-key-32-bytes-long
|
||||||
|
|
||||||
|
# Production 模式
|
||||||
|
export ADAPTER_MODE=production
|
||||||
|
export DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ocdp?sslmode=disable
|
||||||
|
export JWT_SECRET=your-production-secret
|
||||||
|
export ENCRYPTION_KEY=your-production-encryption-key-32-bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
export VITE_USE_MOCK=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 故障排查命令
|
||||||
|
|
||||||
|
### 问题诊断
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看完整日志
|
||||||
|
docker compose logs --tail=1000 > debug.log
|
||||||
|
|
||||||
|
# 查看特定时间段日志
|
||||||
|
docker compose logs --since 10m backend
|
||||||
|
|
||||||
|
# 查看容器详细信息
|
||||||
|
docker compose exec backend env
|
||||||
|
|
||||||
|
# 检查配置
|
||||||
|
docker compose config
|
||||||
|
|
||||||
|
# 验证 docker-compose.yml
|
||||||
|
docker compose -f docker-compose.yml config
|
||||||
|
docker compose -f docker-compose.dev.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 端口冲突检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看端口占用
|
||||||
|
sudo lsof -i :8080
|
||||||
|
sudo lsof -i :5173
|
||||||
|
sudo lsof -i :3000
|
||||||
|
sudo lsof -i :5432
|
||||||
|
|
||||||
|
# 杀死进程
|
||||||
|
sudo kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 磁盘空间清理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 删除未使用的容器
|
||||||
|
docker container prune
|
||||||
|
|
||||||
|
# 删除未使用的镜像
|
||||||
|
docker image prune
|
||||||
|
|
||||||
|
# 删除未使用的数据卷
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# 删除所有未使用的资源
|
||||||
|
docker system prune -a --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 实用技巧
|
||||||
|
|
||||||
|
### 1. 后台运行并查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-dev-bg && make docker-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重启并查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart backend && docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建并启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-build && make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 清理并重新开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-down && docker system prune -f && make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 快速测试 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 保存 token
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')
|
||||||
|
|
||||||
|
# 使用 token
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/registries
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 更多资源
|
||||||
|
|
||||||
|
- [快速开始](./QUICK_START.md)
|
||||||
|
- [Docker 服务架构](./DOCKER_SERVICES.md)
|
||||||
|
- [项目重构总结](./PROJECT_RESTRUCTURE_SUMMARY.md)
|
||||||
|
- [README](./README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<sub>命令速查表 - 最后更新:2025-11-09</sub>
|
||||||
|
</div>
|
||||||
|
|
||||||
56
Makefile
Normal file
56
Makefile
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# ============================================================
|
||||||
|
# OCDP stack orchestration Makefile
|
||||||
|
# run-2: 构建前端静态资源 + 启动 nginx(统一入口)和 backend 栈
|
||||||
|
# clean-2: 清理 run-2 产生的容器 / 卷 / 网络
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
COMPOSE_BIN ?= docker compose
|
||||||
|
|
||||||
|
ROOT_COMPOSE := docker-compose.yml
|
||||||
|
BACKEND_COMPOSE := backend/docker-compose.yml
|
||||||
|
BACKEND_PROFILE := backend
|
||||||
|
|
||||||
|
COMPOSE_STACK := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE) --profile $(BACKEND_PROFILE)
|
||||||
|
COMPOSE_STACK_ALL := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE)
|
||||||
|
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)
|
||||||
|
|
||||||
|
STACK_SERVICES := postgres backend nginx
|
||||||
|
|
||||||
|
.PHONY: run-2 clean-2 build-backend
|
||||||
|
|
||||||
|
run-2:
|
||||||
|
@echo "═══════════════════════════════════════════════"
|
||||||
|
@echo "🚀 run-2: rebuild static assets + start web gateway stack"
|
||||||
|
@echo "═══════════════════════════════════════════════"
|
||||||
|
@echo ""
|
||||||
|
@export COMPOSE_PROJECT_NAME=ocdp && \
|
||||||
|
export ADAPTER_MODE=production && \
|
||||||
|
export BACKEND_BUILD_CONTEXT=$(abspath backend) && \
|
||||||
|
export BACKEND_BUILD_DOCKERFILE=$(abspath backend/Dockerfile) && \
|
||||||
|
export BACKEND_MOCK_BUILD_DOCKERFILE=$(abspath backend/Dockerfile.mock) && \
|
||||||
|
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 "✅ Services online:"
|
||||||
|
@echo "═══════════════════════════════════════════════"
|
||||||
|
|
||||||
|
clean-2:
|
||||||
|
@echo "═══════════════════════════════════════════════"
|
||||||
|
@echo "🧹 clean-2: tearing down run-2 stack"
|
||||||
|
@echo "═══════════════════════════════════════════════"
|
||||||
|
@$(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 "═══════════════════════════════════════════════"
|
||||||
|
|
||||||
|
|
||||||
413
QUICK_START.md
Normal file
413
QUICK_START.md
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
# OCDP 快速开始指南
|
||||||
|
|
||||||
|
## 🚀 5分钟快速体验
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- (可选) Make 工具
|
||||||
|
|
||||||
|
### 第一步:克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ocdp-go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:选择运行模式
|
||||||
|
|
||||||
|
#### 方式 1: 开发模式(推荐用于日常开发)
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- ✅ 后端 Mock 模式(无需数据库)
|
||||||
|
- ✅ 热重载(代码修改自动生效)
|
||||||
|
- ✅ 快速启动
|
||||||
|
- ✅ 适合快速迭代开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Make
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 或直接使用 Docker Compose
|
||||||
|
docker compose --profile dev up
|
||||||
|
```
|
||||||
|
|
||||||
|
**访问服务**:
|
||||||
|
- 前端:http://localhost:5173
|
||||||
|
- 后端:http://localhost:8080
|
||||||
|
- API 文档:http://localhost:8080/api/v1
|
||||||
|
|
||||||
|
**默认账号**:
|
||||||
|
- 用户名:`admin`
|
||||||
|
- 密码:`admin123`
|
||||||
|
|
||||||
|
#### 方式 2: 生产模式(用于完整功能测试)
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- ✅ 真实数据库
|
||||||
|
- ✅ 完整功能
|
||||||
|
- ✅ 生产环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Make
|
||||||
|
make docker-prod
|
||||||
|
|
||||||
|
# 或直接使用 Docker Compose
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**访问服务**:
|
||||||
|
- 前端:http://localhost:3000
|
||||||
|
- 后端:http://localhost:8080
|
||||||
|
- 数据库:localhost:5432
|
||||||
|
|
||||||
|
#### 方式 3: 独立测试单个服务
|
||||||
|
|
||||||
|
**测试后端**:
|
||||||
|
```bash
|
||||||
|
make docker-test-backend
|
||||||
|
# 访问:http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试前端**:
|
||||||
|
```bash
|
||||||
|
make docker-test-frontend
|
||||||
|
# 访问:http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:验证服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查后端健康状态
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# 登录获取 token
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123"}'
|
||||||
|
|
||||||
|
# 获取 registries 列表
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第四步:开始开发
|
||||||
|
|
||||||
|
#### 修改后端代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑任意 Go 文件
|
||||||
|
vim backend/cmd/api/main.go
|
||||||
|
|
||||||
|
# Air 会自动检测变化并重新编译(开发模式)
|
||||||
|
# 查看日志确认重载
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改前端代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑任意 React 组件
|
||||||
|
vim frontend/src/App.tsx
|
||||||
|
|
||||||
|
# Vite HMR 会自动更新浏览器(开发模式)
|
||||||
|
# 无需手动刷新页面
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止并删除容器
|
||||||
|
make docker-down
|
||||||
|
|
||||||
|
# 或
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 完全清理(包括数据卷)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 常用命令速查
|
||||||
|
|
||||||
|
### Docker 命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `make docker-dev` | 启动开发环境 |
|
||||||
|
| `make docker-prod` | 启动生产环境 |
|
||||||
|
| `make docker-test-backend` | 测试后端 |
|
||||||
|
| `make docker-test-frontend` | 测试前端 |
|
||||||
|
| `make docker-logs` | 查看所有日志 |
|
||||||
|
| `make docker-down` | 停止所有服务 |
|
||||||
|
| `make docker-build` | 重新构建镜像 |
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 所有服务
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 只看后端
|
||||||
|
docker compose logs -f backend
|
||||||
|
|
||||||
|
# 只看前端
|
||||||
|
docker compose logs -f frontend
|
||||||
|
|
||||||
|
# 只看最后 100 行
|
||||||
|
docker compose logs --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 进入容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入后端容器
|
||||||
|
docker compose exec backend sh
|
||||||
|
|
||||||
|
# 进入前端容器
|
||||||
|
docker compose exec frontend sh
|
||||||
|
|
||||||
|
# 进入数据库容器
|
||||||
|
docker compose exec postgres psql -U postgres -d ocdp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重启所有
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# 重启后端
|
||||||
|
docker compose restart backend
|
||||||
|
|
||||||
|
# 重启前端
|
||||||
|
docker compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 使用场景指南
|
||||||
|
|
||||||
|
### 场景 1: 我要开发新功能
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动开发环境
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 2. 修改代码(自动热重载)
|
||||||
|
|
||||||
|
# 3. 查看日志
|
||||||
|
make docker-logs
|
||||||
|
|
||||||
|
# 4. 测试功能
|
||||||
|
# 访问 http://localhost:5173
|
||||||
|
|
||||||
|
# 5. 停止
|
||||||
|
make docker-down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 我要测试完整功能
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动生产环境(包含数据库)
|
||||||
|
make docker-prod
|
||||||
|
|
||||||
|
# 2. 访问前端
|
||||||
|
# http://localhost:3000
|
||||||
|
|
||||||
|
# 3. 停止
|
||||||
|
make docker-down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 我只想测试后端 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动后端 Mock
|
||||||
|
make docker-test-backend-bg
|
||||||
|
|
||||||
|
# 2. 测试 API
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
|
||||||
|
# 3. 停止
|
||||||
|
docker compose -f docker-compose.mock.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4: 我只想测试前端界面
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动前端 Mock
|
||||||
|
make docker-test-frontend-bg
|
||||||
|
|
||||||
|
# 2. 访问前端
|
||||||
|
# http://localhost:3000
|
||||||
|
|
||||||
|
# 3. 停止
|
||||||
|
docker compose -f docker-compose.mock.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 5: 代码修改不生效
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止所有服务
|
||||||
|
make docker-down
|
||||||
|
|
||||||
|
# 2. 重新构建镜像
|
||||||
|
make docker-build --no-cache
|
||||||
|
|
||||||
|
# 3. 重新启动
|
||||||
|
make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 常见问题
|
||||||
|
|
||||||
|
### Q1: 端口被占用怎么办?
|
||||||
|
|
||||||
|
**问题**:启动时报错 "port is already allocated"
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 查看占用端口的进程
|
||||||
|
sudo lsof -i :8080
|
||||||
|
sudo lsof -i :5173
|
||||||
|
sudo lsof -i :3000
|
||||||
|
|
||||||
|
# 杀掉进程或修改 docker-compose.yml 中的端口映射
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q2: 容器启动失败
|
||||||
|
|
||||||
|
**查看详细错误**:
|
||||||
|
```bash
|
||||||
|
docker compose logs backend
|
||||||
|
docker compose logs frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见原因**:
|
||||||
|
- 端口冲突
|
||||||
|
- 依赖服务未就绪
|
||||||
|
- 配置错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# 清理并重新启动
|
||||||
|
docker compose down -v
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q3: 热重载不工作
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
```bash
|
||||||
|
# 确认 Air 是否运行
|
||||||
|
docker compose logs backend | grep "air"
|
||||||
|
|
||||||
|
# 检查文件挂载
|
||||||
|
docker compose exec backend ls -la /app
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
```bash
|
||||||
|
# 确认 Vite 是否运行
|
||||||
|
docker compose logs frontend | grep "VITE"
|
||||||
|
|
||||||
|
# 重启前端
|
||||||
|
docker compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4: 数据库连接失败
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
```bash
|
||||||
|
# 数据库是否运行
|
||||||
|
docker compose ps postgres
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
docker compose exec postgres pg_isready
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 确保使用生产模式(`docker compose up`)
|
||||||
|
- 等待数据库健康检查通过(约 10-20 秒)
|
||||||
|
- 检查 `DATABASE_URL` 环境变量
|
||||||
|
|
||||||
|
### Q5: 前端无法连接后端
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
```bash
|
||||||
|
# 后端是否运行
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# 网络连接
|
||||||
|
docker network inspect ocdp-network
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 确认后端服务运行正常
|
||||||
|
- 检查 `VITE_API_BASE_URL` 环境变量
|
||||||
|
- 检查浏览器控制台错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 进一步学习
|
||||||
|
|
||||||
|
### 详细文档
|
||||||
|
|
||||||
|
- [Docker 服务架构](./DOCKER_SERVICES.md) - 完整的服务说明
|
||||||
|
- [开发指南](./docs/development/specification.md) - 开发规范
|
||||||
|
- [API 文档](./backend/docs/openapi.yaml) - OpenAPI 规范
|
||||||
|
- [部署指南](./docs/deployment/docker-guide.md) - 生产部署
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ocdp-go/
|
||||||
|
├── backend/ # Go 后端服务
|
||||||
|
├── frontend/ # React 前端应用
|
||||||
|
├── api/ # OpenAPI 规范
|
||||||
|
├── docs/ # 项目文档
|
||||||
|
├── docker-compose.yml # 生产模式
|
||||||
|
├── docker-compose.dev.yml # 开发模式
|
||||||
|
├── docker-compose.mock.yml # Mock 模式
|
||||||
|
└── Makefile # 便捷命令
|
||||||
|
```
|
||||||
|
|
||||||
|
### 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Frontend │────▶│ Backend │
|
||||||
|
│ (React) │◀────│ (Go API) │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ Database │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 开始使用
|
||||||
|
|
||||||
|
现在你已经准备好开始使用 OCDP 了!
|
||||||
|
|
||||||
|
**推荐的开发流程**:
|
||||||
|
1. ✅ 使用 `make docker-dev` 启动开发环境
|
||||||
|
2. ✅ 修改代码(自动热重载)
|
||||||
|
3. ✅ 使用浏览器测试功能
|
||||||
|
4. ✅ 使用 `make docker-test-backend` 测试后端 API
|
||||||
|
5. ✅ 使用 `make docker-prod` 进行完整测试
|
||||||
|
|
||||||
|
**需要帮助?**
|
||||||
|
- 查看 [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) 了解详细配置
|
||||||
|
- 查看 [GitHub Issues](https://github.com/your-repo/issues) 报告问题
|
||||||
|
- 查看项目文档获取更多信息
|
||||||
|
|
||||||
|
Happy Coding! 🚀
|
||||||
|
|
||||||
336
README.md
Normal file
336
README.md
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
# OCDP - Open Cloud Development Platform
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://go.dev/)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
|
||||||
|
开源云原生开发平台,用于管理 Kubernetes 集群、OCI Registry 和 Helm Charts 部署。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 🎯 **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
|
||||||
|
- 📦 **Artifact 浏览** - 浏览和管理 Helm Charts、容器镜像
|
||||||
|
- 🚀 **一键部署** - 可视化部署 Helm Charts 到 Kubernetes 集群
|
||||||
|
- 🔍 **智能过滤** - 按 MediaType 过滤 artifacts(chart、image、other)
|
||||||
|
- 🎨 **现代 UI** - 响应式设计,基于 React + TypeScript
|
||||||
|
- 🔐 **安全认证** - JWT 认证,加密存储敏感信息
|
||||||
|
- 🐳 **容器化** - 完整的 Docker 支持,多种运行模式
|
||||||
|
- 🔄 **热重载** - 开发模式支持代码热重载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- (可选) Make 工具
|
||||||
|
|
||||||
|
### 5分钟快速体验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆项目
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ocdp-go
|
||||||
|
|
||||||
|
# 2. 启动开发环境(Mock 模式,无需数据库)
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 3. 访问应用
|
||||||
|
# - 前端:http://localhost:5173
|
||||||
|
# - 后端:http://localhost:8080
|
||||||
|
# - 默认账号:admin / admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细指南**:查看 [快速开始指南](./QUICK_START.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 文档导航
|
||||||
|
|
||||||
|
### 📖 核心文档(必读)
|
||||||
|
- 🚀 [快速开始](./QUICK_START.md) - 5分钟快速上手
|
||||||
|
- 📋 [使用指南](./USAGE_GUIDE.md) - 详细使用说明(推荐)
|
||||||
|
- 💡 [命令速查表](./COMMANDS_CHEATSHEET.md) - 常用命令快速参考
|
||||||
|
- 📚 [文档中心](./docs/README.md) - 完整文档索引
|
||||||
|
|
||||||
|
### 🔧 专业文档
|
||||||
|
- 📐 [开发规范](./docs/development/specification.md) - 代码规范和架构
|
||||||
|
- 🚢 [部署指南](./docs/deployment/docker-guide.md) - 生产环境部署
|
||||||
|
- 🔒 [安全实践](./docs/security/security-implementation.md) - 安全配置
|
||||||
|
- 🎨 [功能文档](./docs/features/) - 详细功能说明
|
||||||
|
|
||||||
|
### 🔗 其他资源
|
||||||
|
- 📋 [OpenAPI 规范](./backend/docs/openapi.yaml) - RESTful API 定义
|
||||||
|
- 📦 [历史文档](./docs/archive/) - 项目演进历史
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 技术架构
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- 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│
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行模式
|
||||||
|
|
||||||
|
| 模式 | 特点 | 适用场景 | 命令 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| **开发模式** | Mock 数据,热重载 | 日常开发 | `make docker-dev` |
|
||||||
|
| **生产模式** | 真实数据库,完整功能 | 生产部署 | `make docker-prod` |
|
||||||
|
| **Mock 模式** | 独立测试单个服务 | 单元测试 | `make docker-test-backend` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 开发指南
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ocdp-go/
|
||||||
|
├── backend/ # Go 后端服务
|
||||||
|
│ ├── cmd/api/ # 应用入口
|
||||||
|
│ ├── internal/ # 内部代码
|
||||||
|
│ │ ├── adapter/ # 适配器层
|
||||||
|
│ │ ├── domain/ # 领域层
|
||||||
|
│ │ └── bootstrap/ # 启动配置
|
||||||
|
│ ├── Dockerfile # 生产环境
|
||||||
|
│ ├── Dockerfile.dev # 开发环境
|
||||||
|
│ └── Dockerfile.mock # Mock 测试
|
||||||
|
│
|
||||||
|
├── frontend/ # React 前端应用
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── core/ # 核心功能
|
||||||
|
│ │ ├── features/ # 功能模块
|
||||||
|
│ │ └── shared/ # 共享组件
|
||||||
|
│ ├── Dockerfile # 生产环境
|
||||||
|
│ ├── Dockerfile.dev # 开发环境
|
||||||
|
│ └── Dockerfile.mock # Mock 测试
|
||||||
|
│
|
||||||
|
├── api/ # API 规范
|
||||||
|
│ └── openapi.yaml # OpenAPI 定义
|
||||||
|
│
|
||||||
|
├── docs/ # 项目文档
|
||||||
|
│ ├── features/ # 功能文档
|
||||||
|
│ ├── deployment/ # 部署文档
|
||||||
|
│ └── development/ # 开发文档
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # 统一配置(使用 profiles)
|
||||||
|
└── Makefile # 便捷命令
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker 服务(推荐)
|
||||||
|
make docker-dev # 启动开发环境
|
||||||
|
make docker-prod # 启动生产环境
|
||||||
|
make docker-test-backend # 测试后端
|
||||||
|
make docker-test-frontend # 测试前端
|
||||||
|
make docker-logs # 查看日志
|
||||||
|
make docker-down # 停止服务
|
||||||
|
|
||||||
|
# OpenAPI 工作流
|
||||||
|
make openapi-validate # 验证 API 规范
|
||||||
|
make openapi-gen # 生成代码
|
||||||
|
make openapi-docs # 生成文档
|
||||||
|
|
||||||
|
# 本地开发(不使用 Docker)
|
||||||
|
make install # 安装依赖
|
||||||
|
make dev-local # 启动本地开发
|
||||||
|
make test # 运行测试
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发工作流
|
||||||
|
|
||||||
|
1. **启动开发环境**:
|
||||||
|
```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>
|
||||||
127
START_BACKEND.md
Normal file
127
START_BACKEND.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# 启动和更新后端服务指南
|
||||||
|
|
||||||
|
## 方式一:使用 Makefile(推荐)
|
||||||
|
|
||||||
|
### 启动完整服务(前端 + 后端 + Nginx)
|
||||||
|
```bash
|
||||||
|
make run-2
|
||||||
|
```
|
||||||
|
|
||||||
|
这会:
|
||||||
|
1. 重新构建前端静态资源
|
||||||
|
2. 重新构建后端镜像
|
||||||
|
3. 启动 PostgreSQL、Backend 和 Nginx 服务
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
```bash
|
||||||
|
make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式二:只更新后端服务
|
||||||
|
|
||||||
|
### 1. 停止服务
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重新构建并启动后端
|
||||||
|
```bash
|
||||||
|
# 设置环境变量(必须!)
|
||||||
|
export COMPOSE_PROJECT_NAME=ocdp
|
||||||
|
export ADAPTER_MODE=production
|
||||||
|
export BACKEND_BUILD_CONTEXT=$(pwd)/backend
|
||||||
|
export BACKEND_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile
|
||||||
|
export BACKEND_MOCK_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile.mock
|
||||||
|
export INIT_DB_SQL_PATH=$(pwd)/backend/scripts/init-db.sql
|
||||||
|
|
||||||
|
# 重新构建后端镜像
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend build backend
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d postgres backend nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 或者使用一行命令(强制重建)
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build --force-recreate backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式三:只重启后端容器(不重新构建)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重启后端容器
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend restart backend
|
||||||
|
|
||||||
|
# 或者先停止再启动
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend stop backend
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend start backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式四:查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有服务日志
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f
|
||||||
|
|
||||||
|
# 只查看后端日志
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式五:开发模式(只启动数据库,本地运行后端)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只启动 PostgreSQL
|
||||||
|
docker compose -f backend/docker-compose.yml up -d postgres
|
||||||
|
|
||||||
|
# 然后在本地运行后端
|
||||||
|
cd backend
|
||||||
|
go run cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看运行中的服务
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend ps
|
||||||
|
|
||||||
|
# 查看后端容器状态
|
||||||
|
docker ps | grep ocdp-backend
|
||||||
|
|
||||||
|
# 进入后端容器
|
||||||
|
docker exec -it ocdp-backend sh
|
||||||
|
|
||||||
|
# 查看后端健康状态
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# 停止所有服务
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||||
|
|
||||||
|
# 停止并删除数据卷(清理数据)
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速更新后端(推荐流程)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止服务
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
|
||||||
|
|
||||||
|
# 2. 重新构建并启动(使用 Makefile)
|
||||||
|
make run-2
|
||||||
|
|
||||||
|
# 或者手动执行
|
||||||
|
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
355
USAGE_GUIDE.md
Normal file
355
USAGE_GUIDE.md
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
# OCDP 使用指南
|
||||||
|
|
||||||
|
## 🎯 统一的 docker-compose.yml
|
||||||
|
|
||||||
|
现在所有配置都整合在一个文件中,使用 **profiles** 区分不同运行模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 方式 1: 使用 Make(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 生产模式
|
||||||
|
make docker-prod
|
||||||
|
|
||||||
|
# 测试后端
|
||||||
|
make docker-test-backend
|
||||||
|
|
||||||
|
# 测试前端
|
||||||
|
make docker-test-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: 使用 Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
docker compose --profile dev up
|
||||||
|
|
||||||
|
# 生产模式
|
||||||
|
docker compose --profile production up
|
||||||
|
|
||||||
|
# Mock 测试模式(后端)
|
||||||
|
docker compose --profile mock up backend-mock
|
||||||
|
|
||||||
|
# Mock 测试模式(前端)
|
||||||
|
docker compose --profile mock up frontend-mock
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 三种运行模式
|
||||||
|
|
||||||
|
### 1. 开发模式(Dev Profile)
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- ✅ 后端 Mock 模式(无需数据库)
|
||||||
|
- ✅ 热重载(Air + Vite HMR)
|
||||||
|
- ✅ 快速启动
|
||||||
|
|
||||||
|
**启动命令**:
|
||||||
|
```bash
|
||||||
|
# 前台运行
|
||||||
|
docker compose --profile dev up
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-dev
|
||||||
|
make docker-dev-bg
|
||||||
|
```
|
||||||
|
|
||||||
|
**访问地址**:
|
||||||
|
- 前端:http://localhost:5173
|
||||||
|
- 后端:http://localhost:8080
|
||||||
|
|
||||||
|
**服务列表**:
|
||||||
|
- `backend-dev` - 后端开发服务
|
||||||
|
- `frontend-dev` - 前端开发服务
|
||||||
|
|
||||||
|
### 2. 生产模式(Production Profile)
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- ✅ 真实数据库(PostgreSQL + Redis)
|
||||||
|
- ✅ 完整功能
|
||||||
|
- ✅ 生产环境配置
|
||||||
|
|
||||||
|
**启动命令**:
|
||||||
|
```bash
|
||||||
|
# 后台运行
|
||||||
|
docker compose --profile production up -d
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-prod
|
||||||
|
make docker-up
|
||||||
|
```
|
||||||
|
|
||||||
|
**访问地址**:
|
||||||
|
- 前端:http://localhost:3000
|
||||||
|
- 后端:http://localhost:8080
|
||||||
|
- 数据库:localhost:5432
|
||||||
|
|
||||||
|
**服务列表**:
|
||||||
|
- `postgres` - PostgreSQL 数据库
|
||||||
|
- `redis` - Redis 缓存
|
||||||
|
- `backend-prod` - 后端生产服务
|
||||||
|
- `frontend-prod` - 前端生产服务
|
||||||
|
|
||||||
|
### 3. Mock 测试模式(Mock Profile)
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- ✅ 独立测试单个服务
|
||||||
|
- ✅ 无外部依赖
|
||||||
|
- ✅ 快速启动
|
||||||
|
|
||||||
|
**测试后端**:
|
||||||
|
```bash
|
||||||
|
# 前台运行
|
||||||
|
docker compose --profile mock up backend-mock
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker compose --profile mock up -d backend-mock
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-test-backend
|
||||||
|
make docker-test-backend-bg
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试前端**:
|
||||||
|
```bash
|
||||||
|
# 前台运行
|
||||||
|
docker compose --profile mock up frontend-mock
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker compose --profile mock up -d frontend-mock
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-test-frontend
|
||||||
|
make docker-test-frontend-bg
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用操作
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 查看特定服务日志
|
||||||
|
docker compose logs -f backend-dev
|
||||||
|
docker compose logs -f frontend-dev
|
||||||
|
docker compose logs -f backend-prod
|
||||||
|
docker compose logs -f backend-mock
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-logs
|
||||||
|
make docker-logs-backend
|
||||||
|
make docker-logs-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止所有服务
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 停止特定 profile 的服务
|
||||||
|
docker compose --profile dev down
|
||||||
|
docker compose --profile production down
|
||||||
|
docker compose --profile mock down
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重启所有运行的服务
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# 重启特定服务
|
||||||
|
docker compose restart backend-dev
|
||||||
|
docker compose restart frontend-dev
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-restart
|
||||||
|
make docker-restart-backend
|
||||||
|
make docker-restart-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建所有镜像
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# 无缓存构建
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-build
|
||||||
|
make docker-build-no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看运行的服务
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 查看详细状态
|
||||||
|
make docker-status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 开发工具
|
||||||
|
|
||||||
|
### 启动 pgAdmin 和 Swagger UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 需要先启动生产模式
|
||||||
|
docker compose --profile production --profile tools up -d
|
||||||
|
|
||||||
|
# 或使用 Make
|
||||||
|
make docker-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**访问地址**:
|
||||||
|
- pgAdmin:http://localhost:5050
|
||||||
|
- Swagger UI:http://localhost:8081
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 服务对比表
|
||||||
|
|
||||||
|
| 特性 | 开发模式 | 生产模式 | Mock 模式 |
|
||||||
|
|------|---------|---------|----------|
|
||||||
|
| **Profile** | `dev` | `production` | `mock` |
|
||||||
|
| **后端服务** | `backend-dev` | `backend-prod` | `backend-mock` |
|
||||||
|
| **前端服务** | `frontend-dev` | `frontend-prod` | `frontend-mock` |
|
||||||
|
| **数据库** | ❌ Mock | ✅ PostgreSQL | ❌ Mock |
|
||||||
|
| **前端端口** | 5173 | 3000 | 3000 |
|
||||||
|
| **热重载** | ✅ | ❌ | ❌ |
|
||||||
|
| **启动时间** | ~15秒 | ~30秒 | ~5秒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 使用场景示例
|
||||||
|
|
||||||
|
### 场景 1: 日常开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动开发环境
|
||||||
|
make docker-dev
|
||||||
|
|
||||||
|
# 2. 修改代码(自动热重载)
|
||||||
|
|
||||||
|
# 3. 查看日志
|
||||||
|
make docker-logs
|
||||||
|
|
||||||
|
# 4. 停止
|
||||||
|
make docker-down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 测试后端 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动后端 Mock
|
||||||
|
make docker-test-backend-bg
|
||||||
|
|
||||||
|
# 2. 测试 API
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
|
||||||
|
# 3. 停止
|
||||||
|
docker compose --profile mock down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 生产环境部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 配置环境变量
|
||||||
|
export JWT_SECRET="your-secret"
|
||||||
|
export ENCRYPTION_KEY="your-32-byte-key"
|
||||||
|
|
||||||
|
# 2. 启动生产环境
|
||||||
|
make docker-prod
|
||||||
|
|
||||||
|
# 3. 检查状态
|
||||||
|
make docker-status
|
||||||
|
|
||||||
|
# 4. 启动管理工具
|
||||||
|
make docker-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4: 完全重置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止并删除所有容器和数据
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
# 2. 删除镜像
|
||||||
|
docker rmi $(docker images | grep ocdp | awk '{print $3}')
|
||||||
|
|
||||||
|
# 3. 重新构建
|
||||||
|
make docker-build
|
||||||
|
|
||||||
|
# 4. 重新启动
|
||||||
|
make docker-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 环境变量
|
||||||
|
|
||||||
|
### 开发模式环境变量
|
||||||
|
|
||||||
|
后端自动使用:
|
||||||
|
- `ADAPTER_MODE=mock`
|
||||||
|
- `JWT_SECRET=dev-secret-key`
|
||||||
|
|
||||||
|
### 生产模式环境变量
|
||||||
|
|
||||||
|
可通过 `.env` 文件或导出环境变量设置:
|
||||||
|
```bash
|
||||||
|
export JWT_SECRET="your-production-secret"
|
||||||
|
export ENCRYPTION_KEY="your-production-encryption-key-32-bytes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [README.md](./README.md) - 项目概述
|
||||||
|
- [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) - 详细架构说明
|
||||||
|
- [COMMANDS_CHEATSHEET.md](./COMMANDS_CHEATSHEET.md) - 命令速查表
|
||||||
|
- [QUICK_START.md](./QUICK_START.md) - 快速开始指南
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 优势
|
||||||
|
|
||||||
|
通过整合到单个 `docker-compose.yml` 文件:
|
||||||
|
|
||||||
|
- ✅ **更简洁**:只需维护一个配置文件
|
||||||
|
- ✅ **更清晰**:所有服务定义在同一处
|
||||||
|
- ✅ **更灵活**:通过 profiles 轻松切换模式
|
||||||
|
- ✅ **更易维护**:减少配置重复
|
||||||
|
- ✅ **向后兼容**:Make 命令保持不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<sub>简化配置,提升效率!🚀</sub>
|
||||||
|
</div>
|
||||||
|
|
||||||
45
backend/.air.toml
Normal file
45
backend/.air.toml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Air 配置文件 - 用于开发环境热重载
|
||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ./cmd/api"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
50
backend/.dockerignore
Normal file
50
backend/.dockerignore
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
backend.log
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
examples/
|
||||||
|
|
||||||
62
backend/.gitignore
vendored
Normal file
62
backend/.gitignore
vendored
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
|
storage.json
|
||||||
|
|
||||||
|
# Production configs (keep example configs)
|
||||||
|
config/bootstrap.prod.json
|
||||||
|
config/bootstrap.production.json
|
||||||
|
config/*.prod.json
|
||||||
|
config/*.production.json
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
secrets/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Swagger 生成的文档 (可通过 swag init 重新生成)
|
||||||
|
docs/swagger/
|
||||||
452
backend/BOOTSTRAP-DATA.md
Normal file
452
backend/BOOTSTRAP-DATA.md
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
# Bootstrap 预注入数据说明
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
Bootstrap 功能在应用启动时自动预注入初始数据,帮助快速搭建开发/测试环境。
|
||||||
|
|
||||||
|
**配置文件**: `config/bootstrap.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 预注入数据内容
|
||||||
|
|
||||||
|
### 1️⃣ 用户 (Users)
|
||||||
|
|
||||||
|
预注入 **1 个管理员账户**:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| **username** | `admin` | 管理员用户名 |
|
||||||
|
| **password** | `admin123` | 初始密码(⚠️ 生产环境请修改) |
|
||||||
|
| **email** | `admin@example.com` | 邮箱地址 |
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 登录后台管理系统
|
||||||
|
- 测试用户认证功能
|
||||||
|
- 管理集群和 Registry
|
||||||
|
|
||||||
|
**密码加密**: 使用 bcrypt 加密存储
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Registry (OCI 镜像仓库)
|
||||||
|
|
||||||
|
预注入 **1 个 Harbor Registry**:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| **name** | `Harbor Production` | Registry 名称 |
|
||||||
|
| **url** | `https://harbor.example.com` | Registry 地址 |
|
||||||
|
| **description** | `Production Harbor Registry` | 描述 |
|
||||||
|
| **username** | `admin` | Registry 用户名 |
|
||||||
|
| **password** | `Harbor12345` | Registry 密码(加密存储) |
|
||||||
|
| **insecure** | `false` | 是否跳过 SSL 验证 |
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 浏览 Helm Chart 制品
|
||||||
|
- 拉取 OCI Artifacts
|
||||||
|
- 测试 Registry 连接
|
||||||
|
|
||||||
|
**密码加密**: 使用 AES 加密存储(基于 `ENCRYPTION_KEY` 环境变量)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ Kubernetes 集群 (Clusters)
|
||||||
|
|
||||||
|
预注入 **1 个测试集群**:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| **name** | `Test Cluster` | 集群名称 |
|
||||||
|
| **host** | `https://kubernetes.example.com:6443` | Kubernetes API Server 地址 |
|
||||||
|
| **description** | `Test Kubernetes Cluster` | 描述 |
|
||||||
|
| **caData** | `LS0tLS1CRUdJTi1D...` | CA 证书(Base64 编码) |
|
||||||
|
| **certData** | `LS0tLS1CRUdJTi1D...` | 客户端证书(Base64 编码) |
|
||||||
|
| **keyData** | `LS0tLS1CRUdJTi1S...` | 客户端密钥(Base64 编码) |
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 部署 Helm Chart 应用
|
||||||
|
- 查看集群状态和资源
|
||||||
|
- 测试 Kubernetes 集成
|
||||||
|
|
||||||
|
**证书加密**: CA/Cert/Key 使用 AES 加密存储
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Bootstrap 模式
|
||||||
|
|
||||||
|
### Mock 模式 (run-0)
|
||||||
|
- ✅ Bootstrap 启用
|
||||||
|
- ✅ 数据存储在内存中
|
||||||
|
- ✅ 重启后数据重置
|
||||||
|
|
||||||
|
### 真实模式 (run-1, run-2)
|
||||||
|
- ✅ Bootstrap 启用
|
||||||
|
- ✅ 数据存储在 PostgreSQL
|
||||||
|
- ✅ 重启后数据持久化
|
||||||
|
- ⚠️ **避免重复**: 如果数据已存在会跳过创建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 配置文件
|
||||||
|
|
||||||
|
### 完整配置示例 (`config/bootstrap.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registries": [
|
||||||
|
{
|
||||||
|
"name": "Harbor Production",
|
||||||
|
"url": "https://harbor.example.com",
|
||||||
|
"description": "Production Harbor Registry",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "Harbor12345",
|
||||||
|
"insecure": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"name": "Test Cluster",
|
||||||
|
"host": "https://kubernetes.example.com:6443",
|
||||||
|
"description": "Test Kubernetes Cluster",
|
||||||
|
"caData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
|
||||||
|
"certData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
|
||||||
|
"keyData": "LS0tLS1CRUdJTi1SU0EgUFJJVkFURSBLRVktLS0tLQ=="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 自定义配置
|
||||||
|
|
||||||
|
### 方式 1: 修改配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑配置
|
||||||
|
vim config/bootstrap.json
|
||||||
|
|
||||||
|
# 重启应用
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 2: 通过环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BOOTSTRAP_CONFIG_JSON='{
|
||||||
|
"enabled": true,
|
||||||
|
"users": [
|
||||||
|
{"username": "myuser", "password": "mypass", "email": "user@example.com"}
|
||||||
|
],
|
||||||
|
"registries": [],
|
||||||
|
"clusters": []
|
||||||
|
}'
|
||||||
|
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 3: 指定配置文件路径
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BOOTSTRAP_CONFIG_FILE=/path/to/custom-bootstrap.json
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 安全建议
|
||||||
|
|
||||||
|
### ⚠️ 生产环境注意事项
|
||||||
|
|
||||||
|
1. **修改默认密码**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "YourStrongPasswordHere" // ⚠️ 修改
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **设置强加密密钥**
|
||||||
|
```bash
|
||||||
|
# 生成 32 字节随机密钥
|
||||||
|
export ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **使用真实证书**
|
||||||
|
- 替换 `caData`, `certData`, `keyData` 为真实集群证书
|
||||||
|
- 确保证书有效期和权限正确
|
||||||
|
|
||||||
|
4. **禁用 Bootstrap(可选)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **删除配置文件**
|
||||||
|
```bash
|
||||||
|
# 首次启动后删除(数据已导入)
|
||||||
|
rm config/bootstrap.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试验证
|
||||||
|
|
||||||
|
### 验证用户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录测试
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 预期返回: {"token": "eyJhbGc..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证 Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 Registry 列表
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
|
||||||
|
# 预期返回:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "...",
|
||||||
|
# "name": "Harbor Production",
|
||||||
|
# "url": "https://harbor.example.com"
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证 Cluster
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看集群列表
|
||||||
|
curl http://localhost:8080/api/v1/clusters
|
||||||
|
|
||||||
|
# 预期返回:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# "id": "...",
|
||||||
|
# "name": "Test Cluster",
|
||||||
|
# "host": "https://kubernetes.example.com:6443"
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 启动日志示例
|
||||||
|
|
||||||
|
### 成功的 Bootstrap 日志
|
||||||
|
|
||||||
|
```
|
||||||
|
🌱 Starting bootstrap seeding...
|
||||||
|
↳ Seeding 1 user(s)...
|
||||||
|
✓ User 'admin' created
|
||||||
|
↳ Seeding 1 registry(ies)...
|
||||||
|
✓ Registry 'Harbor Production' created (credentials encrypted)
|
||||||
|
↳ Seeding 1 cluster(s)...
|
||||||
|
✓ Cluster 'Test Cluster' created (credentials encrypted)
|
||||||
|
✅ Bootstrap seeding completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据已存在的日志
|
||||||
|
|
||||||
|
```
|
||||||
|
🌱 Starting bootstrap seeding...
|
||||||
|
↳ Seeding 1 user(s)...
|
||||||
|
⊙ User 'admin' already exists, skipping
|
||||||
|
↳ Seeding 1 registry(ies)...
|
||||||
|
⊙ Registry 'Harbor Production' already exists, skipping
|
||||||
|
↳ Seeding 1 cluster(s)...
|
||||||
|
⊙ Cluster 'Test Cluster' already exists, skipping
|
||||||
|
✅ Bootstrap seeding completed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 重置数据
|
||||||
|
|
||||||
|
### 重置 Mock 模式数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mock 数据存储在内存,重启即重置
|
||||||
|
make run-0
|
||||||
|
# Ctrl+C
|
||||||
|
make run-0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重置真实数据库数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理并重新创建
|
||||||
|
make clean-1
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 更多示例
|
||||||
|
|
||||||
|
### 添加多个用户
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "developer",
|
||||||
|
"password": "dev123",
|
||||||
|
"email": "dev@example.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "operator",
|
||||||
|
"password": "ops123",
|
||||||
|
"email": "ops@example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加多个 Registry
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": [
|
||||||
|
{
|
||||||
|
"name": "Harbor Production",
|
||||||
|
"url": "https://harbor.example.com",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "password1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Hub",
|
||||||
|
"url": "https://registry-1.docker.io",
|
||||||
|
"username": "myuser",
|
||||||
|
"password": "password2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GitHub Container Registry",
|
||||||
|
"url": "https://ghcr.io",
|
||||||
|
"username": "github-user",
|
||||||
|
"password": "ghp_token"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加多个集群
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"name": "Dev Cluster",
|
||||||
|
"host": "https://dev-k8s.example.com:6443",
|
||||||
|
"description": "Development Environment",
|
||||||
|
"caData": "...",
|
||||||
|
"certData": "...",
|
||||||
|
"keyData": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Staging Cluster",
|
||||||
|
"host": "https://staging-k8s.example.com:6443",
|
||||||
|
"description": "Staging Environment",
|
||||||
|
"caData": "...",
|
||||||
|
"certData": "...",
|
||||||
|
"keyData": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Production Cluster",
|
||||||
|
"host": "https://prod-k8s.example.com:6443",
|
||||||
|
"description": "Production Environment",
|
||||||
|
"caData": "...",
|
||||||
|
"certData": "...",
|
||||||
|
"keyData": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 故障排查
|
||||||
|
|
||||||
|
### 问题 1: Bootstrap 不生效
|
||||||
|
|
||||||
|
**症状**: 启动后没有预注入数据
|
||||||
|
|
||||||
|
**检查**:
|
||||||
|
```bash
|
||||||
|
# 1. 检查配置文件是否存在
|
||||||
|
ls -la config/bootstrap.json
|
||||||
|
|
||||||
|
# 2. 检查 enabled 是否为 true
|
||||||
|
cat config/bootstrap.json | jq .enabled
|
||||||
|
|
||||||
|
# 3. 查看启动日志
|
||||||
|
# 应该看到 "Starting bootstrap seeding..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2: 密码不正确
|
||||||
|
|
||||||
|
**症状**: 无法使用预注入的用户登录
|
||||||
|
|
||||||
|
**原因**: 密码在配置文件中可能已修改
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
# 查看配置文件中的密码
|
||||||
|
cat config/bootstrap.json | jq '.users[0].password'
|
||||||
|
|
||||||
|
# 使用正确的密码测试
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-d '{"username":"admin","password":"配置文件中的密码"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3: 重复创建报错
|
||||||
|
|
||||||
|
**症状**: 日志显示 "duplicate key" 错误
|
||||||
|
|
||||||
|
**原因**: 数据库中已存在同名记录
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
- 正常现象,Bootstrap 会自动跳过
|
||||||
|
- 如需重新创建,使用 `make clean-1` 清理数据库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- **配置示例**: `config/bootstrap.example.json`
|
||||||
|
- **代码实现**: `internal/bootstrap/seeder.go`
|
||||||
|
- **架构文档**: `docs/architecture.md` - Bootstrap 预注入章节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2025-11-10
|
||||||
|
|
||||||
324
backend/CODE_FIRST_GUIDE.md
Normal file
324
backend/CODE_FIRST_GUIDE.md
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
# Code First API 开发指南
|
||||||
|
|
||||||
|
## 🎯 概述
|
||||||
|
|
||||||
|
本项目现已支持 **Code First** 方式开发 API:
|
||||||
|
1. **后端**:使用 Swagger 注释从代码生成 OpenAPI 文档
|
||||||
|
2. **前端**:使用 Orval 从 OpenAPI 文档生成 TypeScript 客户端
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 工具链
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **swaggo/swag**: 从 Go 代码注释生成 OpenAPI/Swagger 文档
|
||||||
|
- 文档:https://github.com/swaggo/swag
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **Orval**: 从 OpenAPI 规范生成 TypeScript API 客户端
|
||||||
|
- 文档:https://orval.dev/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 开发流程
|
||||||
|
|
||||||
|
### 1. 后端:添加 Swagger 注释
|
||||||
|
|
||||||
|
#### 主程序注释 (cmd/api/main.go)
|
||||||
|
```go
|
||||||
|
// @title OCDP Backend API
|
||||||
|
// @version 1.0
|
||||||
|
// @description OCDP (Open Cloud Development Platform) Backend API
|
||||||
|
//
|
||||||
|
// @host localhost:8080
|
||||||
|
// @BasePath /api/v1
|
||||||
|
//
|
||||||
|
// @securityDefinitions.apikey BearerAuth
|
||||||
|
// @in header
|
||||||
|
// @name Authorization
|
||||||
|
package main
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Handler 方法注释示例
|
||||||
|
```go
|
||||||
|
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||||
|
// @Summary 列出 Repository 中的所有 Artifacts
|
||||||
|
// @Description 列出指定 Repository 中的所有 Artifact,支持按类型过滤
|
||||||
|
// @Tags Artifacts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||||
|
// @Param media_type query string false "过滤类型 (all, chart, image, other)" default(all)
|
||||||
|
// @Success 200 {array} dto.TagResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts [get]
|
||||||
|
func (h *ArtifactHandler) ListArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 生成 OpenAPI 文档
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 backend 目录下运行
|
||||||
|
cd backend
|
||||||
|
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||||
|
|
||||||
|
# 生成的文件在 docs/swagger/ 目录:
|
||||||
|
# - swagger.json
|
||||||
|
# - swagger.yaml
|
||||||
|
# - docs.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 前端:重新生成 API 客户端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 frontend 目录下运行
|
||||||
|
cd frontend
|
||||||
|
npm run openapi-gen
|
||||||
|
|
||||||
|
# 或者
|
||||||
|
orval
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的文件在 `src/api/generated-orval/` 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 完整工作流
|
||||||
|
|
||||||
|
### 添加新 API 的步骤
|
||||||
|
|
||||||
|
#### 1. 后端开发
|
||||||
|
```bash
|
||||||
|
# 1.1 创建 DTO
|
||||||
|
# internal/adapter/input/http/dto/new_feature_dto.go
|
||||||
|
|
||||||
|
# 1.2 创建 Handler 并添加 Swagger 注释
|
||||||
|
# internal/adapter/input/http/rest/new_feature_handler.go
|
||||||
|
|
||||||
|
# 1.3 注册路由
|
||||||
|
# cmd/api/main.go
|
||||||
|
|
||||||
|
# 1.4 生成 OpenAPI 文档
|
||||||
|
cd backend
|
||||||
|
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||||
|
|
||||||
|
# 1.5 复制到主文档 (可选)
|
||||||
|
cp docs/swagger/swagger.yaml docs/openapi.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 前端开发
|
||||||
|
```bash
|
||||||
|
# 2.1 重新生成 API 客户端
|
||||||
|
cd frontend
|
||||||
|
npm run openapi-gen
|
||||||
|
|
||||||
|
# 2.2 使用生成的客户端
|
||||||
|
import { listArtifacts } from '@/api/generated-orval/api';
|
||||||
|
|
||||||
|
const data = await listArtifacts({
|
||||||
|
registryId: 'xxx',
|
||||||
|
repositoryName: 'charts/nginx'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Swagger 注释速查
|
||||||
|
|
||||||
|
### 常用标签
|
||||||
|
|
||||||
|
| 标签 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `@Summary` | 简短描述 | `@Summary 列出所有用户` |
|
||||||
|
| `@Description` | 详细描述 | `@Description 返回系统中的所有用户列表` |
|
||||||
|
| `@Tags` | API 分组 | `@Tags Users` |
|
||||||
|
| `@Accept` | 接受的格式 | `@Accept json` |
|
||||||
|
| `@Produce` | 返回的格式 | `@Produce json` |
|
||||||
|
| `@Param` | 参数 | `@Param id path string true "User ID"` |
|
||||||
|
| `@Success` | 成功响应 | `@Success 200 {object} dto.UserResponse` |
|
||||||
|
| `@Failure` | 失败响应 | `@Failure 404 {object} dto.ErrorResponse` |
|
||||||
|
| `@Router` | 路由定义 | `@Router /users/{id} [get]` |
|
||||||
|
| `@Security` | 安全认证 | `@Security BearerAuth` |
|
||||||
|
|
||||||
|
### 参数类型
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 路径参数 (path)
|
||||||
|
@Param id path string true "User ID"
|
||||||
|
|
||||||
|
// 查询参数 (query)
|
||||||
|
@Param page query int false "Page number" default(1)
|
||||||
|
@Param size query int false "Page size" default(20)
|
||||||
|
|
||||||
|
// 请求体 (body)
|
||||||
|
@Param request body dto.CreateUserRequest true "User data"
|
||||||
|
|
||||||
|
// Header 参数 (header)
|
||||||
|
@Param X-Request-ID header string false "Request ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 单个对象
|
||||||
|
@Success 200 {object} dto.UserResponse
|
||||||
|
|
||||||
|
// 数组
|
||||||
|
@Success 200 {array} dto.UserResponse
|
||||||
|
|
||||||
|
// 自定义响应
|
||||||
|
@Success 200 {object} map[string]interface{}
|
||||||
|
|
||||||
|
// 多个可能的响应
|
||||||
|
@Success 200 {object} dto.UserResponse "Success"
|
||||||
|
@Success 201 {object} dto.UserResponse "Created"
|
||||||
|
@Failure 400 {object} dto.ErrorResponse "Bad Request"
|
||||||
|
@Failure 404 {object} dto.ErrorResponse "Not Found"
|
||||||
|
@Failure 500 {object} dto.ErrorResponse "Internal Error"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 最佳实践
|
||||||
|
|
||||||
|
### 1. 命名规范
|
||||||
|
|
||||||
|
| 层级 | 风格 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **路径变量** | snake_case | `{registry_id}`, `{repository_name}` |
|
||||||
|
| **查询参数** | snake_case | `?media_type=chart` |
|
||||||
|
| **JSON 字段** | camelCase | `repositoryName`, `mediaType` |
|
||||||
|
| **Go 结构体** | PascalCase | `RepositoryName`, `MediaType` |
|
||||||
|
|
||||||
|
### 2. DTO 定义示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ArtifactResponse Artifact 响应
|
||||||
|
type ArtifactResponse struct {
|
||||||
|
RepositoryName string `json:"repositoryName"` // Go: PascalCase, JSON: camelCase
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 完整的 Handler 示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetArtifact 获取 artifact 详情
|
||||||
|
// @Summary 获取 Artifact 详情
|
||||||
|
// @Description 获取指定 Artifact 的详细信息
|
||||||
|
// @Tags Artifacts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||||
|
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||||
|
// @Success 200 {object} dto.ArtifactResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse "Artifact not found"
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse "Internal server error"
|
||||||
|
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference} [get]
|
||||||
|
func (h *ArtifactHandler) GetArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
repositoryName := vars["repository_name"]
|
||||||
|
reference := vars["reference"]
|
||||||
|
|
||||||
|
artifact, err := h.artifactService.GetArtifact(r.Context(), registryID, repositoryName, reference)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Artifact not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.ArtifactResponse{
|
||||||
|
RepositoryName: artifact.Repository,
|
||||||
|
Tag: artifact.Tag,
|
||||||
|
Digest: artifact.Digest,
|
||||||
|
Type: string(artifact.Type),
|
||||||
|
Size: artifact.Size,
|
||||||
|
CreatedAt: artifact.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 故障排除
|
||||||
|
|
||||||
|
### 问题 1: swag 命令未找到
|
||||||
|
```bash
|
||||||
|
# 解决方案:安装 swag
|
||||||
|
go install github.com/swaggo/swag/cmd/swag@latest
|
||||||
|
|
||||||
|
# 确保 $GOPATH/bin 在 PATH 中
|
||||||
|
export PATH=$PATH:$(go env GOPATH)/bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2: 生成的文档不完整
|
||||||
|
```bash
|
||||||
|
# 确保使用了正确的参数
|
||||||
|
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
|
||||||
|
|
||||||
|
# 检查注释格式是否正确
|
||||||
|
# 注释必须紧邻函数定义,中间不能有空行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 3: 前端客户端生成失败
|
||||||
|
```bash
|
||||||
|
# 检查 OpenAPI 文档是否有效
|
||||||
|
cd frontend
|
||||||
|
npx @apidevtools/swagger-cli validate ../backend/docs/openapi.yaml
|
||||||
|
|
||||||
|
# 清理并重新生成
|
||||||
|
rm -rf src/api/generated-orval
|
||||||
|
npm run openapi-gen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资源
|
||||||
|
|
||||||
|
- **Swaggo 文档**: https://github.com/swaggo/swag
|
||||||
|
- **Swaggo 声明式注释**: https://github.com/swaggo/swag#declarative-comments-format
|
||||||
|
- **Orval 文档**: https://orval.dev/
|
||||||
|
- **OpenAPI 规范**: https://swagger.io/specification/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 当前状态
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
- [x] 安装 swag 工具
|
||||||
|
- [x] 添加主程序 Swagger 注释
|
||||||
|
- [x] 为 Artifact Handler 添加完整注释
|
||||||
|
- [x] 生成 OpenAPI 文档
|
||||||
|
- [x] 前端配置 Orval
|
||||||
|
- [x] 重新生成前端客户端
|
||||||
|
|
||||||
|
### 待完成 (可选)
|
||||||
|
- [ ] 为其他 Handler 添加 Swagger 注释
|
||||||
|
- [ ] Auth Handler
|
||||||
|
- [ ] Cluster Handler
|
||||||
|
- [ ] Registry Handler
|
||||||
|
- [ ] Instance Handler
|
||||||
|
- [ ] Monitoring Handler
|
||||||
|
- [ ] 添加更详细的错误响应定义
|
||||||
|
- [ ] 添加请求/响应示例
|
||||||
|
- [ ] 配置 CI/CD 自动生成文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
现在项目支持 Code First 开发流程:
|
||||||
|
1. **后端开发者**:写代码 + 注释 → 生成 OpenAPI
|
||||||
|
2. **前端开发者**:使用 OpenAPI → 生成 TypeScript 客户端
|
||||||
|
3. **文档自动同步**:代码即文档,保证一致性
|
||||||
|
|
||||||
|
这确保了 API 文档始终与代码保持同步!✨
|
||||||
223
backend/DEVELOPMENT.md
Normal file
223
backend/DEVELOPMENT.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# 开发环境快速指南
|
||||||
|
|
||||||
|
## 📋 前置要求
|
||||||
|
|
||||||
|
- Go 1.21+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Air(热加载工具): `go install github.com/cosmtrek/air@latest`
|
||||||
|
|
||||||
|
## 🎯 设计理念
|
||||||
|
|
||||||
|
本项目使用 **Docker Compose Profile** 机制,通过单一的 `docker-compose.yml` 文件支持三种运行模式,避免配置文件重复。
|
||||||
|
|
||||||
|
## 🚀 三种运行模式
|
||||||
|
|
||||||
|
### 模式 0: Mock 模式(最快)
|
||||||
|
|
||||||
|
纯本地运行,无需任何外部依赖,所有服务都是 Mock。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-0
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- ✅ 启动最快(秒启动)
|
||||||
|
- ✅ 无需 Docker
|
||||||
|
- ✅ 支持热加载
|
||||||
|
- ✅ 适合快速功能开发
|
||||||
|
|
||||||
|
**停止:** `Ctrl+C`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 模式 1: 开发模式(推荐)
|
||||||
|
|
||||||
|
Docker 提供真实的 PostgreSQL,后端在本地运行并支持热加载。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- ✅ 真实的数据库
|
||||||
|
- ✅ 支持热加载
|
||||||
|
- ✅ 代码修改实时生效
|
||||||
|
- ✅ 适合日常开发
|
||||||
|
|
||||||
|
**停止:** `Ctrl+C` 停止后端(数据库容器继续运行)
|
||||||
|
|
||||||
|
**清理:** `make clean-1` 清理数据库和临时文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 模式 2: 生产模式
|
||||||
|
|
||||||
|
所有服务完全容器化,模拟生产环境。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- ✅ 完全容器化
|
||||||
|
- ✅ 接近生产环境
|
||||||
|
- ✅ 后台运行
|
||||||
|
- ❌ 无热加载
|
||||||
|
|
||||||
|
**查看日志:**
|
||||||
|
```bash
|
||||||
|
docker compose --profile backend logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
**停止:**
|
||||||
|
```bash
|
||||||
|
docker compose --profile backend down
|
||||||
|
```
|
||||||
|
|
||||||
|
**清理:** `make clean-2` 清理所有容器和构建产物
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 清理命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理模式 1 的产物(依赖容器 + 临时文件)
|
||||||
|
make clean-1
|
||||||
|
|
||||||
|
# 清理模式 2 的产物(所有容器 + 构建产物)
|
||||||
|
make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 常用工作流
|
||||||
|
|
||||||
|
### 日常开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动开发环境
|
||||||
|
make run-1
|
||||||
|
|
||||||
|
# ... 编码、测试 ...
|
||||||
|
# 代码修改会自动重新编译
|
||||||
|
|
||||||
|
# 下班停止(Ctrl+C)
|
||||||
|
# 第二天继续(依赖容器还在运行,直接启动)
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重置数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 清理并重新开始
|
||||||
|
make clean-1
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试生产部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动生产模式
|
||||||
|
make run-2
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# 测试完成后清理
|
||||||
|
make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 环境变量配置
|
||||||
|
|
||||||
|
复制并修改环境变量配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
主要配置项:
|
||||||
|
|
||||||
|
- `ADAPTER_MODE`: 设置为 `mock` 启用 Mock 模式(模式 0)
|
||||||
|
- `POSTGRES_*`: 数据库配置(模式 1、2)
|
||||||
|
- `JWT_SECRET`: JWT 密钥(生产环境必须修改)
|
||||||
|
- `ENCRYPTION_KEY`: 加密密钥(必须 32 字节)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 文件说明
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `Makefile` | 开发命令入口(5个核心命令) |
|
||||||
|
| `.air.toml` | 热加载配置 |
|
||||||
|
| `docker-compose.yml` | 统一配置文件(使用 profile 区分模式) |
|
||||||
|
| `Dockerfile` | 生产镜像构建配置 |
|
||||||
|
| `DEVELOPMENT.md` | 本文档 |
|
||||||
|
|
||||||
|
### Docker Compose Profile 说明
|
||||||
|
|
||||||
|
- **无 profile**: 只启动 postgres(用于模式 1)
|
||||||
|
- **--profile backend**: 启动 postgres + backend(用于模式 2)
|
||||||
|
- **--profile mock**: 启动 backend-mock(独立的 mock 模式容器)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### Q: 模式 1 启动失败,提示端口被占用?
|
||||||
|
|
||||||
|
A: 检查是否有其他 PostgreSQL 实例在运行:
|
||||||
|
```bash
|
||||||
|
docker ps | grep postgres
|
||||||
|
# 或者修改 .env 中的 POSTGRES_PORT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何查看依赖服务状态?
|
||||||
|
|
||||||
|
A:
|
||||||
|
```bash
|
||||||
|
docker compose ps # 模式 1(只有 postgres)
|
||||||
|
docker compose --profile backend ps # 模式 2(postgres + backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 模式 1 和模式 2 的数据库数据会互相影响吗?
|
||||||
|
|
||||||
|
A: 会共享同一个数据库容器和数据卷 `ocdp-postgres-data`。
|
||||||
|
- 模式 1: 使用本地 air 连接到 postgres 容器
|
||||||
|
- 模式 2: 使用 backend 容器连接到 postgres 容器
|
||||||
|
- 数据是持久化的,切换模式不会丢失数据
|
||||||
|
|
||||||
|
### Q: 如何完全清理所有 Docker 资源?
|
||||||
|
|
||||||
|
A:
|
||||||
|
```bash
|
||||||
|
make clean-1
|
||||||
|
make clean-2
|
||||||
|
docker system prune -a --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 提示
|
||||||
|
|
||||||
|
- **首次使用**: 推荐从 `make run-0` 开始,快速验证环境
|
||||||
|
- **日常开发**: 使用 `make run-1`,享受热加载和真实数据库
|
||||||
|
- **部署前测试**: 使用 `make run-2`,确保容器化部署没问题
|
||||||
|
- **数据持久化**: 模式 1 和 2 的数据库数据都会持久化,`Ctrl+C` 不会丢失数据
|
||||||
|
- **完全重置**: 使用 `clean-*` 命令会删除数据卷,数据会丢失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速参考
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make # 显示帮助
|
||||||
|
make run-0 # Mock 模式
|
||||||
|
make run-1 # 开发模式
|
||||||
|
make run-2 # 生产模式
|
||||||
|
make clean-1 # 清理开发环境
|
||||||
|
make clean-2 # 清理生产环境
|
||||||
|
```
|
||||||
|
|
||||||
41
backend/Dockerfile
Normal file
41
backend/Dockerfile
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# ==================================================
|
||||||
|
# OCDP Backend - Dockerfile
|
||||||
|
# 构建 backend 服务(连接 PostgreSQL)
|
||||||
|
# ==================================================
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# Runtime
|
||||||
|
# ==================================================
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates curl
|
||||||
|
|
||||||
|
RUN addgroup -g 1000 ocdp && \
|
||||||
|
adduser -D -u 1000 -G ocdp ocdp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/ocdp-backend .
|
||||||
|
COPY --from=builder /build/config ./config
|
||||||
|
|
||||||
|
RUN chown -R ocdp:ocdp /app
|
||||||
|
|
||||||
|
USER ocdp
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
CMD ["./ocdp-backend"]
|
||||||
76
backend/Makefile
Normal file
76
backend/Makefile
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
.PHONY: help run-0 run-1 run-2 clean-1 clean-2
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo ""
|
||||||
|
@echo "╔════════════════════════════════════════════════════╗"
|
||||||
|
@echo "║ Backend Development ║"
|
||||||
|
@echo "╚════════════════════════════════════════════════════╝"
|
||||||
|
@echo ""
|
||||||
|
@echo "🚀 Run:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make run-0 Hot reload + Mock"
|
||||||
|
@echo " • No dependencies"
|
||||||
|
@echo " • Ctrl+C to stop"
|
||||||
|
@echo ""
|
||||||
|
@echo " make run-1 Hot reload + Deps in container"
|
||||||
|
@echo " • Real PostgreSQL (Docker)"
|
||||||
|
@echo " • Ctrl+C to stop backend"
|
||||||
|
@echo ""
|
||||||
|
@echo " make run-2 All in container (background)"
|
||||||
|
@echo " • Everything in Docker"
|
||||||
|
@echo " • Use 'docker compose down' to stop"
|
||||||
|
@echo ""
|
||||||
|
@echo "🧹 Clean:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make clean-1 Clean run-1 artifacts"
|
||||||
|
@echo " (deps containers + volumes + tmp/)"
|
||||||
|
@echo ""
|
||||||
|
@echo " make clean-2 Clean run-2 artifacts"
|
||||||
|
@echo " (all containers + volumes + bin/)"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Run Commands
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
run-0:
|
||||||
|
@echo "🎭 Run-0: Hot reload + Mock"
|
||||||
|
@echo "────────────────────────────────"
|
||||||
|
ADAPTER_MODE=mock air -c .air.toml
|
||||||
|
|
||||||
|
run-1:
|
||||||
|
@echo "🔥 Run-1: Hot reload + Deps in container"
|
||||||
|
@echo "────────────────────────────────"
|
||||||
|
@docker compose up -d postgres
|
||||||
|
@sleep 5
|
||||||
|
@echo "✅ Dependencies ready, starting backend..."
|
||||||
|
@echo ""
|
||||||
|
ADAPTER_MODE=production \
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
|
||||||
|
air -c .air.toml
|
||||||
|
|
||||||
|
run-2:
|
||||||
|
@echo "⚡ Run-2: All in container (background)"
|
||||||
|
@echo "────────────────────────────────"
|
||||||
|
@echo "→ Starting backend stack via Docker Compose (postgres + backend)"
|
||||||
|
ADAPTER_MODE=production docker compose --profile backend up -d
|
||||||
|
@echo ""
|
||||||
|
@echo "✅ Backend stack running in Docker (use 'docker compose --profile backend down' to stop)"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Clean Commands
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
clean-1:
|
||||||
|
@echo "🧹 Cleaning run-1 artifacts..."
|
||||||
|
@docker compose down -v
|
||||||
|
@rm -rf tmp/
|
||||||
|
@echo "✅ run-1 cleaned"
|
||||||
|
|
||||||
|
clean-2:
|
||||||
|
@echo "🧹 Cleaning run-2 artifacts..."
|
||||||
|
@docker compose --profile backend down -v
|
||||||
|
@rm -rf bin/ dist/
|
||||||
|
@echo "✅ run-2 cleaned"
|
||||||
133
backend/QUICK-REFERENCE.md
Normal file
133
backend/QUICK-REFERENCE.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# 快速参考卡片
|
||||||
|
|
||||||
|
## 🚀 五个核心命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-0 # Mock 模式(秒启动,无需 Docker)
|
||||||
|
make run-1 # 开发模式(Docker 依赖 + 本地热加载)
|
||||||
|
make run-2 # 生产模式(全部容器化)
|
||||||
|
make clean-1 # 清理开发环境
|
||||||
|
make clean-2 # 清理生产环境
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 模式对比
|
||||||
|
|
||||||
|
| 特性 | run-0 | run-1 | run-2 |
|
||||||
|
|------|-------|-------|-------|
|
||||||
|
| **后端位置** | 本地 | 本地 | 容器 |
|
||||||
|
| **数据库** | Mock | 容器 | 容器 |
|
||||||
|
| **热加载** | ✅ | ✅ | ❌ |
|
||||||
|
| **启动速度** | ⚡ 秒启 | 🔥 5秒 | 🐳 30秒 |
|
||||||
|
| **适用场景** | 快速开发 | 日常开发 | 部署测试 |
|
||||||
|
| **依赖 Docker** | ❌ | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 使用场景
|
||||||
|
|
||||||
|
### 场景 1: 快速功能开发
|
||||||
|
```bash
|
||||||
|
make run-0
|
||||||
|
# 修改代码,自动重新编译
|
||||||
|
# Ctrl+C 停止
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 日常开发(推荐)
|
||||||
|
```bash
|
||||||
|
make run-1
|
||||||
|
# 修改代码,自动重新编译
|
||||||
|
# Ctrl+C 停止后端(数据库继续运行)
|
||||||
|
|
||||||
|
# 第二天继续
|
||||||
|
make run-1 # 数据库还在,直接启动
|
||||||
|
|
||||||
|
# 重置数据库
|
||||||
|
make clean-1
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 部署前测试
|
||||||
|
```bash
|
||||||
|
make run-2
|
||||||
|
# 查看日志
|
||||||
|
docker compose --profile backend logs -f
|
||||||
|
|
||||||
|
# 测试完成
|
||||||
|
make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Docker Compose 直接使用
|
||||||
|
|
||||||
|
### 启动依赖(run-1 等价)
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
go run cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动完整环境(run-2 等价)
|
||||||
|
```bash
|
||||||
|
docker compose --profile backend up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看状态
|
||||||
|
```bash
|
||||||
|
docker compose ps # run-1
|
||||||
|
docker compose --profile backend ps # run-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止
|
||||||
|
```bash
|
||||||
|
docker compose down # run-1
|
||||||
|
docker compose --profile backend down # run-2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 关键文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `Makefile` | 5个核心命令 |
|
||||||
|
| `docker-compose.yml` | 使用 profile 的统一配置 |
|
||||||
|
| `.air.toml` | 热加载配置 |
|
||||||
|
| `env.example` | 环境变量模板 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Compose Profile 原理
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# 默认启动(无需 profile)
|
||||||
|
|
||||||
|
backend:
|
||||||
|
profiles: [backend] # 需要 --profile backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用方式**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d # 只启动 postgres
|
||||||
|
docker compose --profile backend up -d # 启动 postgres + backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 快速提示
|
||||||
|
|
||||||
|
- 首次使用运行: `go install github.com/cosmtrek/air@latest`
|
||||||
|
- 查看帮助: `make` 或 `make help`
|
||||||
|
- 完整文档: 查看 `DEVELOPMENT.md`
|
||||||
|
- 审查报告: 查看 `REVIEW.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 一句话总结
|
||||||
|
|
||||||
|
**三种模式,五个命令,一个 Docker Compose 文件。**
|
||||||
|
|
||||||
343
backend/README.md
Normal file
343
backend/README.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# OCDP Backend
|
||||||
|
|
||||||
|
基于 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+
|
||||||
|
- PostgreSQL 15+ (生产模式)
|
||||||
|
- Docker & Docker Compose (可选)
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有命令
|
||||||
|
make help
|
||||||
|
|
||||||
|
# 开发
|
||||||
|
make dev # 开发模式(热重载)
|
||||||
|
make build # 构建
|
||||||
|
make run-mock # Mock 模式运行
|
||||||
|
make run-prod # Production 模式运行
|
||||||
|
|
||||||
|
# Docker Compose
|
||||||
|
make mock # Mock 模式
|
||||||
|
make prod # 生产模式
|
||||||
|
make logs # 查看日志
|
||||||
|
make status # 查看状态
|
||||||
|
make stop # 停止服务
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
make db-up # 启动数据库
|
||||||
|
make db-psql # 连接数据库
|
||||||
|
make db-backup # 备份数据库
|
||||||
|
make pgadmin # 启动 pgAdmin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/api/ # 程序入口
|
||||||
|
├── internal/
|
||||||
|
│ ├── domain/ # 🎯 领域层(核心)
|
||||||
|
│ │ ├── entity/ # 实体
|
||||||
|
│ │ ├── service/ # 业务逻辑
|
||||||
|
│ │ └── repository/ # 接口定义
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── input/http/ # 📥 REST API
|
||||||
|
│ │ └── output/ # 📤 数据库、OCI、Helm
|
||||||
|
│ ├── bootstrap/ # Bootstrap 预注入
|
||||||
|
│ └── pkg/ # 🔧 工具包
|
||||||
|
├── docs/ # 📚 文档
|
||||||
|
├── config/ # ⚙️ 配置
|
||||||
|
└── scripts/ # 🛠️ 脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [架构文档](docs/architecture.md)
|
||||||
|
|
||||||
|
## 🔐 安全配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 必需配置
|
||||||
|
ADAPTER_MODE=production
|
||||||
|
JWT_SECRET=your-jwt-secret
|
||||||
|
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/ocdp
|
||||||
|
|
||||||
|
# 生成安全密钥
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap 预注入
|
||||||
|
|
||||||
|
在 `config/bootstrap.json` 中配置初始数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"users": [
|
||||||
|
{"username": "admin", "password": "admin123", "email": "admin@example.com"}
|
||||||
|
],
|
||||||
|
"registries": [
|
||||||
|
{"name": "harbor", "url": "https://harbor.example.com", "username": "admin", "password": "secret"}
|
||||||
|
],
|
||||||
|
"clusters": [
|
||||||
|
{"name": "prod", "host": "https://k8s.example.com:6443", "caData": "...", "certData": "...", "keyData": "..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [架构文档 - Bootstrap 预注入](docs/architecture.md#bootstrap-预注入)
|
||||||
|
|
||||||
|
## 🌐 服务访问
|
||||||
|
|
||||||
|
| 服务 | 地址 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Backend API | http://localhost:8080/api/v1 | REST API |
|
||||||
|
| Health Check | http://localhost:8080/health | 健康检查 |
|
||||||
|
| PostgreSQL | localhost:5432 | 数据库 |
|
||||||
|
| pgAdmin | http://localhost:5050 | 数据库管理 |
|
||||||
|
|
||||||
|
## 🐛 故障排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
**端口被占用**:
|
||||||
|
```bash
|
||||||
|
# 修改 .env 中的 BACKEND_PORT
|
||||||
|
BACKEND_PORT=8081
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据库连接失败**:
|
||||||
|
```bash
|
||||||
|
# 检查数据库状态
|
||||||
|
docker compose ps postgres
|
||||||
|
docker compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**完全重置**:
|
||||||
|
```bash
|
||||||
|
# 停止并删除所有数据
|
||||||
|
docker compose down -v
|
||||||
|
docker compose --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
更多问题参见 [部署文档 - 故障排查](docs/deployment.md#故障排查)
|
||||||
|
|
||||||
|
## 📊 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| **语言** | Go 1.21+ |
|
||||||
|
| **Web 框架** | gorilla/mux |
|
||||||
|
| **OCI 客户端** | ORAS Go SDK v2 |
|
||||||
|
| **Helm 集成** | Helm SDK |
|
||||||
|
| **Kubernetes** | client-go |
|
||||||
|
| **数据库** | PostgreSQL 15+ |
|
||||||
|
| **容器化** | Docker, Docker Compose |
|
||||||
|
| **热重载** | Air |
|
||||||
|
|
||||||
|
## 🔗 相关资源
|
||||||
|
|
||||||
|
### 规范和文档
|
||||||
|
- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
|
||||||
|
- [OCI Image Specification](https://github.com/opencontainers/image-spec)
|
||||||
|
- [Helm Documentation](https://helm.sh/docs/)
|
||||||
|
|
||||||
|
### 使用的库
|
||||||
|
- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP 路由
|
||||||
|
- [ORAS Go SDK](https://oras.land/docs/category/go-library) - OCI Registry 操作
|
||||||
|
- [Helm SDK](https://helm.sh/docs/topics/advanced/) - Helm 操作
|
||||||
|
- [Kubernetes Client-Go](https://github.com/kubernetes/client-go) - K8s API 客户端
|
||||||
|
|
||||||
|
## 📝 待办事项
|
||||||
|
|
||||||
|
- [ ] 添加单元测试和集成测试
|
||||||
|
- [ ] 实现 Rate Limiting
|
||||||
|
- [ ] 添加审计日志
|
||||||
|
- [ ] 实现 Webhook 通知
|
||||||
|
- [ ] 支持更多 OCI Registry 类型
|
||||||
|
- [ ] 添加 Metrics 和 Tracing
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version**: 2.2.0
|
||||||
|
**Last Updated**: 2025-11-09
|
||||||
|
**Port**: 8080 (default)
|
||||||
283
backend/REVIEW.md
Normal file
283
backend/REVIEW.md
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
# 项目实现审查报告
|
||||||
|
|
||||||
|
## ✅ 要求对照检查
|
||||||
|
|
||||||
|
### 1. 文档结构 ✅
|
||||||
|
|
||||||
|
#### 1.1 README.md ✅
|
||||||
|
- 位置: `/backend/README.md`
|
||||||
|
- 状态: ✅ 已存在
|
||||||
|
- 内容: 项目概述、快速开始、API文档、架构概览
|
||||||
|
|
||||||
|
#### 1.2 docs/ ✅
|
||||||
|
所有文档齐全:
|
||||||
|
|
||||||
|
##### 2.1 architecture.md ✅
|
||||||
|
- 位置: `/backend/docs/architecture.md`
|
||||||
|
- 状态: ✅ 已存在
|
||||||
|
- 内容完整包含:
|
||||||
|
- ✅ 2.1.1 **需求描述** (Requirement Description)
|
||||||
|
- 项目背景
|
||||||
|
- 核心需求
|
||||||
|
- 功能需求
|
||||||
|
- 非功能需求
|
||||||
|
- ✅ 2.1.2 **业务建模** (Business Modeling)
|
||||||
|
- 业务领域
|
||||||
|
- 核心实体
|
||||||
|
- 业务流程
|
||||||
|
- 用例场景
|
||||||
|
- ✅ 2.1.3 **技术建模** (Technical Modeling)
|
||||||
|
- ✅ 2.1.3.1 **六边形架构** (Hexagonal Architecture)
|
||||||
|
- 架构图
|
||||||
|
- 层次说明
|
||||||
|
- 依赖倒置原则
|
||||||
|
- ✅ 2.1.3.2 **技术选型** (Technology Selection)
|
||||||
|
- Go 语言
|
||||||
|
- Web 框架
|
||||||
|
- OCI/Helm 客户端
|
||||||
|
- 数据库选型
|
||||||
|
|
||||||
|
##### 2.2 api-and-test.md ✅
|
||||||
|
- 位置: `/backend/docs/api-and-test.md`
|
||||||
|
- 状态: ✅ 已存在
|
||||||
|
- 内容: REST API 文档、测试指南
|
||||||
|
|
||||||
|
##### 2.3 deployment.md ✅
|
||||||
|
- 位置: `/backend/docs/deployment.md`
|
||||||
|
- 状态: ✅ 已存在
|
||||||
|
- 内容: 部署指南、环境配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 开发模式 ✅
|
||||||
|
|
||||||
|
#### 3.1 Mode 0: Hot Reload + Mock ✅
|
||||||
|
```bash
|
||||||
|
make run-0
|
||||||
|
```
|
||||||
|
- ✅ 热加载支持 (Air)
|
||||||
|
- ✅ Mock 所有依赖 (内存实现)
|
||||||
|
- ✅ 无需任何容器
|
||||||
|
- ✅ 使用环境变量 `ADAPTER_MODE=mock`
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
```makefile
|
||||||
|
run-0:
|
||||||
|
ADAPTER_MODE=mock air -c .air.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Mode 1: Hot Reload + Real Deps in Container ✅
|
||||||
|
```bash
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
- ✅ 热加载支持 (Air)
|
||||||
|
- ✅ 真实依赖 (PostgreSQL) 运行在容器中
|
||||||
|
- ✅ 后端代码在本地运行
|
||||||
|
- ✅ 使用 Docker Compose **无 profile** 启动 postgres
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
```makefile
|
||||||
|
run-1:
|
||||||
|
@docker compose up -d postgres
|
||||||
|
@sleep 5
|
||||||
|
ADAPTER_MODE= \
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
|
||||||
|
air -c .air.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 Mode 2: All in Container ✅
|
||||||
|
```bash
|
||||||
|
make run-2
|
||||||
|
```
|
||||||
|
- ✅ 所有服务容器化
|
||||||
|
- ✅ 无热加载(生产模式)
|
||||||
|
- ✅ 使用 Docker Compose **--profile backend**
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
```makefile
|
||||||
|
run-2:
|
||||||
|
@docker compose --profile backend up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Makefile 设计 ✅
|
||||||
|
|
||||||
|
#### 核心要求: 使用 Profile 共享单一 Docker Compose ✅
|
||||||
|
|
||||||
|
**✅ 已实现**: 使用 `docker-compose.yml` + Profile 机制
|
||||||
|
|
||||||
|
| 命令 | 功能 | Docker Compose 用法 |
|
||||||
|
|------|------|---------------------|
|
||||||
|
| `make run-0` | Mock 模式 | 不使用 Docker |
|
||||||
|
| `make run-1` | 开发模式 | `docker compose up -d postgres` (无 profile) |
|
||||||
|
| `make run-2` | 生产模式 | `docker compose --profile backend up -d` |
|
||||||
|
| `make clean-1` | 清理 run-1 | `docker compose down -v` |
|
||||||
|
| `make clean-2` | 清理 run-2 | `docker compose --profile backend down -v` |
|
||||||
|
|
||||||
|
#### Docker Compose Profile 配置 ✅
|
||||||
|
|
||||||
|
文件: `/backend/docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# 默认启动(无需 profile)
|
||||||
|
# 用于 run-1
|
||||||
|
|
||||||
|
backend:
|
||||||
|
profiles:
|
||||||
|
- backend # 需要 --profile backend 才启动
|
||||||
|
# 用于 run-2
|
||||||
|
|
||||||
|
backend-mock:
|
||||||
|
profiles:
|
||||||
|
- mock # 可选的 mock 容器模式
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- ✅ 单一配置文件,避免重复
|
||||||
|
- ✅ Profile 清晰区分不同模式
|
||||||
|
- ✅ 符合 Docker Compose 最佳实践
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 完整流程对照
|
||||||
|
|
||||||
|
### Process 1: Product Description ✅
|
||||||
|
|
||||||
|
#### 1.1 Requirement ✅
|
||||||
|
- 文档: `docs/architecture.md` - 2.1.1 需求描述
|
||||||
|
- 内容: 项目背景、核心需求、功能需求、非功能需求
|
||||||
|
|
||||||
|
#### 1.2 What API I Need ✅
|
||||||
|
- 文档: `docs/api-and-test.md`
|
||||||
|
- 内容: REST API 端点定义、请求/响应示例
|
||||||
|
|
||||||
|
#### 1.3 Generator Docs ✅
|
||||||
|
所有文档已完整生成:
|
||||||
|
- ✅ `docs/architecture.md` - 架构文档
|
||||||
|
- ✅ Business Modeling - 业务建模
|
||||||
|
- ✅ Technical Modeling - 技术建模
|
||||||
|
- ✅ Hexagonal Architecture - 六边形架构
|
||||||
|
- ✅ Technology Selection - 技术选型
|
||||||
|
- ✅ `docs/api-and-test.md` - API 文档
|
||||||
|
- ✅ `docs/deployment.md` - 部署文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Process 2: Development ✅
|
||||||
|
|
||||||
|
#### 2.1 Develop Mode 1, 2, 3 ✅
|
||||||
|
- ✅ Mode 0 (Mock): `make run-0`
|
||||||
|
- ✅ Mode 1 (Dev): `make run-1`
|
||||||
|
- ✅ Mode 2 (Prod): `make run-2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Process 3: Provide Makefile ✅
|
||||||
|
|
||||||
|
#### 3.1 Five Commands ✅
|
||||||
|
```bash
|
||||||
|
make run-0 # ✅ Hot reload + Mock (memory)
|
||||||
|
make run-1 # ✅ Hot reload + Real deps (container)
|
||||||
|
make run-2 # ✅ All in container
|
||||||
|
make clean-1 # ✅ Clean run-1 artifacts
|
||||||
|
make clean-2 # ✅ Clean run-2 artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Using Profile to Share Docker Compose ✅
|
||||||
|
- ✅ 使用单一的 `docker-compose.yml`
|
||||||
|
- ✅ 通过 `--profile backend` 区分 mode 1 和 mode 2
|
||||||
|
- ✅ Mode 1: `docker compose up -d postgres` (无 profile)
|
||||||
|
- ✅ Mode 2: `docker compose --profile backend up -d`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心设计亮点
|
||||||
|
|
||||||
|
### 1. Docker Compose Profile 机制 ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**传统做法** (❌ 不推荐):
|
||||||
|
```
|
||||||
|
docker-compose.dev.yml # 重复配置 postgres
|
||||||
|
docker-compose.prod.yml # 重复配置 postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**本项目做法** (✅ 推荐):
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (单一文件)
|
||||||
|
services:
|
||||||
|
postgres: # 默认启动,run-1 和 run-2 共享
|
||||||
|
backend:
|
||||||
|
profiles: [backend] # 只在 run-2 时启动
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- ✅ 配置不重复 (DRY 原则)
|
||||||
|
- ✅ 维护简单 (只需修改一个文件)
|
||||||
|
- ✅ 数据共享 (run-1 和 run-2 使用同一个数据库)
|
||||||
|
|
||||||
|
### 2. 三种模式的清晰定位
|
||||||
|
|
||||||
|
| 模式 | 场景 | 启动速度 | 真实依赖 | 热加载 |
|
||||||
|
|------|------|---------|---------|--------|
|
||||||
|
| Mode 0 | 快速开发、单元测试 | ⚡ 秒启动 | ❌ Mock | ✅ |
|
||||||
|
| Mode 1 | 日常开发、集成测试 | 🔥 5秒 | ✅ | ✅ |
|
||||||
|
| Mode 2 | 部署前测试、生产环境 | 🐳 30秒 | ✅ | ❌ |
|
||||||
|
|
||||||
|
### 3. 命令语义清晰
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run-0 → 0 依赖(Mock all)
|
||||||
|
run-1 → 1 部分容器化(Deps in container)
|
||||||
|
run-2 → 2 完全容器化(All in container)
|
||||||
|
|
||||||
|
clean-1 → 清理 run-1 的产物
|
||||||
|
clean-2 → 清理 run-2 的产物
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 最终验证
|
||||||
|
|
||||||
|
### 文档完整性
|
||||||
|
- [x] README.md
|
||||||
|
- [x] docs/architecture.md
|
||||||
|
- [x] 2.1.1 需求描述
|
||||||
|
- [x] 2.1.2 业务建模
|
||||||
|
- [x] 2.1.3 技术建模
|
||||||
|
- [x] 2.1.3.1 六边形架构
|
||||||
|
- [x] 2.1.3.2 技术选型
|
||||||
|
- [x] docs/api-and-test.md
|
||||||
|
- [x] docs/deployment.md
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
- [x] Mode 0: Hot reload + Mock (memory)
|
||||||
|
- [x] Mode 1: Hot reload + Real deps (container)
|
||||||
|
- [x] Mode 2: All in container
|
||||||
|
|
||||||
|
### Makefile
|
||||||
|
- [x] 5 个核心命令
|
||||||
|
- [x] 使用 Profile 共享 Docker Compose
|
||||||
|
- [x] run-1 和 run-2 使用同一个 docker-compose.yml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 结论
|
||||||
|
|
||||||
|
**所有要求均已满足!✅**
|
||||||
|
|
||||||
|
项目完全符合你的设计要求:
|
||||||
|
1. ✅ 完整的文档结构 (README + docs/)
|
||||||
|
2. ✅ 三种开发模式 (0/1/2)
|
||||||
|
3. ✅ 五个核心命令 (run-0/1/2, clean-1/2)
|
||||||
|
4. ✅ 使用 Profile 共享 Docker Compose(关键设计)
|
||||||
|
|
||||||
|
**特别亮点**:
|
||||||
|
- 使用 Docker Compose Profile 避免配置重复
|
||||||
|
- 命名清晰,语义明确 (run-0/1/2)
|
||||||
|
- 产物隔离 (clean-1 vs clean-2)
|
||||||
|
|
||||||
|
**可以直接使用!** 🚀
|
||||||
|
|
||||||
400
backend/TEST-REPORT.md
Normal file
400
backend/TEST-REPORT.md
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
# 测试报告
|
||||||
|
|
||||||
|
**测试日期**: 2025-11-10
|
||||||
|
**测试内容**: Makefile 五个核心命令的功能测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 测试结果总览
|
||||||
|
|
||||||
|
| 命令 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `make run-0` | ✅ 通过 | Mock 模式正常工作 |
|
||||||
|
| `make run-1` | ✅ 通过 | Docker 依赖 + 热加载正常 |
|
||||||
|
| `make run-2` | ✅ 通过 | 完全容器化部署成功 |
|
||||||
|
| `make clean-1` | ✅ 通过 | 清理 run-1 产物完整 |
|
||||||
|
| `make clean-2` | ✅ 通过 | 清理 run-2 产物完整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 详细测试结果
|
||||||
|
|
||||||
|
### 1️⃣ make run-0 (Mock 模式)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
make run-0
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期行为**:
|
||||||
|
- ✅ 使用 Mock 适配器(内存存储)
|
||||||
|
- ✅ 无需任何 Docker 容器
|
||||||
|
- ✅ 支持热加载(Air)
|
||||||
|
- ✅ 环境变量 `ADAPTER_MODE=mock`
|
||||||
|
|
||||||
|
**实际结果**:
|
||||||
|
```
|
||||||
|
✅ 服务启动成功
|
||||||
|
✅ Health API 正常: {"status":"healthy"}
|
||||||
|
✅ Registries API 返回 Mock 数据: 1 条记录
|
||||||
|
✅ Clusters API 返回 Mock 数据: 1 条记录
|
||||||
|
✅ Bootstrap 数据预注入成功
|
||||||
|
```
|
||||||
|
|
||||||
|
**日志输出**:
|
||||||
|
```
|
||||||
|
📝 Configuration: mode=mock, port=8080
|
||||||
|
✅ Output Adapters initialized (mode: mock)
|
||||||
|
🌱 Starting bootstrap seeding...
|
||||||
|
✓ User 'admin' created
|
||||||
|
✓ Registry 'Harbor Production' created
|
||||||
|
✓ Cluster 'Test Cluster' created
|
||||||
|
✅ Bootstrap seeding completed
|
||||||
|
🌐 Server starting on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**: ✅ **完全符合预期**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ make run-1 (Docker 依赖 + 热加载)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
make run-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期行为**:
|
||||||
|
- ✅ 启动 PostgreSQL 容器(使用 `docker compose up -d postgres`)
|
||||||
|
- ✅ 后端代码在本地运行
|
||||||
|
- ✅ 支持热加载(Air)
|
||||||
|
- ✅ 连接真实数据库
|
||||||
|
- ✅ 环境变量 `ADAPTER_MODE=` (空,表示真实模式)
|
||||||
|
|
||||||
|
**实际结果**:
|
||||||
|
```
|
||||||
|
✅ PostgreSQL 容器启动: ocdp-postgres (healthy)
|
||||||
|
✅ 后端连接数据库成功
|
||||||
|
✅ 数据库 Schema 初始化成功
|
||||||
|
✅ Health API 正常
|
||||||
|
✅ Registries API 连接真实 PostgreSQL
|
||||||
|
✅ Clusters API 连接真实 PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker 容器状态**:
|
||||||
|
```
|
||||||
|
NAME IMAGE STATUS
|
||||||
|
ocdp-postgres postgres:17-alpine Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```makefile
|
||||||
|
run-1:
|
||||||
|
@docker compose up -d postgres
|
||||||
|
ADAPTER_MODE= \
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
|
||||||
|
air -c .air.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**: ✅ **完全符合预期**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ make run-2 (完全容器化)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
make run-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期行为**:
|
||||||
|
- ✅ 构建后端 Docker 镜像
|
||||||
|
- ✅ 启动 PostgreSQL + Backend 容器
|
||||||
|
- ✅ 使用 `--profile backend` 区分模式
|
||||||
|
- ✅ 后台运行
|
||||||
|
- ✅ 无热加载
|
||||||
|
|
||||||
|
**实际结果**:
|
||||||
|
```
|
||||||
|
✅ Docker 镜像构建成功: backend-backend
|
||||||
|
✅ 两个容器启动: ocdp-postgres + ocdp-backend
|
||||||
|
✅ 容器健康检查通过
|
||||||
|
✅ Health API 正常
|
||||||
|
✅ Registries API 返回真实数据: 1 条记录
|
||||||
|
✅ Clusters API 返回真实数据: 1 条记录
|
||||||
|
✅ 后端日志正常输出
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker 容器状态**:
|
||||||
|
```
|
||||||
|
NAME IMAGE STATUS
|
||||||
|
ocdp-backend backend-backend Up (healthy)
|
||||||
|
ocdp-postgres postgres:17-alpine Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```makefile
|
||||||
|
run-2:
|
||||||
|
@docker compose --profile backend up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Compose Profile 验证**:
|
||||||
|
- ✅ 使用单一的 `docker-compose.yml`
|
||||||
|
- ✅ PostgreSQL 默认启动(无 profile)
|
||||||
|
- ✅ Backend 需要 `--profile backend` 才启动
|
||||||
|
- ✅ 符合设计要求
|
||||||
|
|
||||||
|
**结论**: ✅ **完全符合预期**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ make clean-1 (清理 run-1 产物)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
make clean-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期行为**:
|
||||||
|
- ✅ 停止并删除 PostgreSQL 容器
|
||||||
|
- ✅ 删除 Docker 数据卷
|
||||||
|
- ✅ 清空 `tmp/` 目录
|
||||||
|
- ✅ 删除 Docker 网络
|
||||||
|
|
||||||
|
**清理前状态**:
|
||||||
|
```
|
||||||
|
Docker 容器: ocdp-postgres (running)
|
||||||
|
tmp/ 目录: 包含 main, build-errors.log, test.txt
|
||||||
|
Docker 卷: ocdp-postgres-data
|
||||||
|
Docker 网络: ocdp-network
|
||||||
|
```
|
||||||
|
|
||||||
|
**清理后状态**:
|
||||||
|
```
|
||||||
|
✅ Docker 容器: 0 个
|
||||||
|
✅ tmp/ 目录: 已清空
|
||||||
|
✅ Docker 卷: ocdp-postgres-data 已删除
|
||||||
|
✅ Docker 网络: ocdp-network 已删除
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行输出**:
|
||||||
|
```
|
||||||
|
🧹 Cleaning run-1 artifacts...
|
||||||
|
Container ocdp-postgres Stopping
|
||||||
|
Container ocdp-postgres Stopped
|
||||||
|
Container ocdp-postgres Removing
|
||||||
|
Container ocdp-postgres Removed
|
||||||
|
Volume ocdp-postgres-data Removing
|
||||||
|
Volume ocdp-postgres-data Removed
|
||||||
|
Network ocdp-network Removed
|
||||||
|
✅ run-1 cleaned
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```makefile
|
||||||
|
clean-1:
|
||||||
|
@docker compose down -v
|
||||||
|
@rm -rf tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**: ✅ **清理完整,无残留**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ make clean-2 (清理 run-2 产物)
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期行为**:
|
||||||
|
- ✅ 停止并删除 Backend + PostgreSQL 容器
|
||||||
|
- ✅ 删除 Docker 数据卷
|
||||||
|
- ✅ 清空 `bin/` 和 `dist/` 目录
|
||||||
|
- ✅ 删除 Docker 网络
|
||||||
|
|
||||||
|
**清理前状态**:
|
||||||
|
```
|
||||||
|
Docker 容器: ocdp-backend (running), ocdp-postgres (running)
|
||||||
|
bin/ 目录: 存在
|
||||||
|
dist/ 目录: 包含 test.tar.gz
|
||||||
|
Docker 卷: ocdp-postgres-data
|
||||||
|
Docker 网络: ocdp-network
|
||||||
|
```
|
||||||
|
|
||||||
|
**清理后状态**:
|
||||||
|
```
|
||||||
|
✅ Docker 容器: 0 个
|
||||||
|
✅ bin/ 目录: 0 个文件
|
||||||
|
✅ dist/ 目录: 0 个文件
|
||||||
|
✅ Docker 卷: ocdp-postgres-data 已删除
|
||||||
|
✅ Docker 网络: ocdp-network 已删除
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行输出**:
|
||||||
|
```
|
||||||
|
🧹 Cleaning run-2 artifacts...
|
||||||
|
Container ocdp-backend Stopping
|
||||||
|
Container ocdp-backend Stopped
|
||||||
|
Container ocdp-backend Removing
|
||||||
|
Container ocdp-backend Removed
|
||||||
|
Container ocdp-postgres Stopping
|
||||||
|
Container ocdp-postgres Stopped
|
||||||
|
Container ocdp-postgres Removing
|
||||||
|
Container ocdp-postgres Removed
|
||||||
|
Volume ocdp-postgres-data Removed
|
||||||
|
Network ocdp-network Removed
|
||||||
|
✅ run-2 cleaned
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```makefile
|
||||||
|
clean-2:
|
||||||
|
@docker compose --profile backend down -v
|
||||||
|
@rm -rf bin/ dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**: ✅ **清理完整,无残留**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心设计验证
|
||||||
|
|
||||||
|
### Docker Compose Profile 机制 ✅
|
||||||
|
|
||||||
|
**验证内容**: 使用单一 `docker-compose.yml` + Profile 实现三种模式
|
||||||
|
|
||||||
|
**验证结果**:
|
||||||
|
|
||||||
|
1. **run-1**: `docker compose up -d postgres`
|
||||||
|
- ✅ 只启动 postgres 服务(无 profile)
|
||||||
|
- ✅ backend 服务不启动(需要 profile)
|
||||||
|
|
||||||
|
2. **run-2**: `docker compose --profile backend up -d`
|
||||||
|
- ✅ 启动 postgres + backend 两个服务
|
||||||
|
- ✅ 使用 `--profile backend` 激活 backend
|
||||||
|
|
||||||
|
3. **共享配置**:
|
||||||
|
- ✅ 两种模式使用同一个 `docker-compose.yml`
|
||||||
|
- ✅ postgres 配置不重复
|
||||||
|
- ✅ 符合 DRY 原则
|
||||||
|
|
||||||
|
**配置文件**:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# 默认启动(无需 profile)
|
||||||
|
|
||||||
|
backend:
|
||||||
|
profiles:
|
||||||
|
- backend # 需要 --profile backend
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**: ✅ **设计正确,实现完美**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 API 功能测试
|
||||||
|
|
||||||
|
### Health Check API
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: ✅ 三种模式均正常
|
||||||
|
```json
|
||||||
|
{"status":"healthy"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Registries API
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/registries
|
||||||
|
```
|
||||||
|
|
||||||
|
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
|
||||||
|
**run-1 (PostgreSQL)**: ✅ 返回真实数据
|
||||||
|
**run-2 (容器)**: ✅ 返回真实数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Clusters API
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/v1/clusters
|
||||||
|
```
|
||||||
|
|
||||||
|
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
|
||||||
|
**run-1 (PostgreSQL)**: ✅ 返回真实数据
|
||||||
|
**run-2 (容器)**: ✅ 返回真实数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 总结
|
||||||
|
|
||||||
|
### ✅ 所有测试通过
|
||||||
|
|
||||||
|
- ✅ **run-0**: Mock 模式工作正常
|
||||||
|
- ✅ **run-1**: Docker 依赖 + 热加载正常
|
||||||
|
- ✅ **run-2**: 完全容器化部署成功
|
||||||
|
- ✅ **clean-1**: 清理 run-1 产物完整
|
||||||
|
- ✅ **clean-2**: 清理 run-2 产物完整
|
||||||
|
|
||||||
|
### ✅ 核心设计验证
|
||||||
|
|
||||||
|
- ✅ **Docker Compose Profile** 机制正确
|
||||||
|
- ✅ **单一配置文件** 实现多模式
|
||||||
|
- ✅ **产物隔离** 清晰
|
||||||
|
- ✅ **命名语义** 明确
|
||||||
|
|
||||||
|
### 🎯 符合所有设计要求
|
||||||
|
|
||||||
|
1. ✅ 三种运行模式(0/1/2)
|
||||||
|
2. ✅ 五个核心命令
|
||||||
|
3. ✅ 使用 Profile 共享 Docker Compose
|
||||||
|
4. ✅ 清理命令分离且完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 使用建议
|
||||||
|
|
||||||
|
### 日常开发流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 快速开发
|
||||||
|
make run-0
|
||||||
|
|
||||||
|
# 需要真实数据库
|
||||||
|
make run-1
|
||||||
|
|
||||||
|
# 测试部署
|
||||||
|
make run-2
|
||||||
|
|
||||||
|
# 清理环境
|
||||||
|
make clean-1 # 或 make clean-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
1. **run-0** 和 **run-1** 是前台运行,`Ctrl+C` 停止
|
||||||
|
2. **run-2** 是后台运行,使用 `docker compose --profile backend down` 停止
|
||||||
|
3. **clean-1** 和 **clean-2** 会删除数据卷,数据会丢失
|
||||||
|
4. run-1 和 run-2 共享同一个 PostgreSQL 容器配置和数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 测试环境
|
||||||
|
|
||||||
|
- **操作系统**: Linux 5.15.0-160-generic
|
||||||
|
- **Docker**: 已安装
|
||||||
|
- **Docker Compose**: 已安装
|
||||||
|
- **Go**: 1.24+
|
||||||
|
- **Air**: 已安装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**测试结论**: 🎉 **所有功能正常,可以投入使用!**
|
||||||
|
|
||||||
BIN
backend/api
Executable file
BIN
backend/api
Executable file
Binary file not shown.
309
backend/cmd/api/main.go
Normal file
309
backend/cmd/api/main.go
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
// @title OCDP Backend API
|
||||||
|
// @version 1.0
|
||||||
|
// @description OCDP (Open Cloud Development Platform) Backend API
|
||||||
|
// @description
|
||||||
|
// @description RESTful API for managing Kubernetes clusters, OCI registries, and Helm deployments.
|
||||||
|
//
|
||||||
|
// @contact.name API Support
|
||||||
|
// @contact.email support@ocdp.io
|
||||||
|
//
|
||||||
|
// @license.name Apache 2.0
|
||||||
|
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
//
|
||||||
|
// @host localhost:8080
|
||||||
|
// @BasePath /api/v1
|
||||||
|
//
|
||||||
|
// @schemes http https
|
||||||
|
//
|
||||||
|
// @securityDefinitions.apikey BearerAuth
|
||||||
|
// @in header
|
||||||
|
// @name Authorization
|
||||||
|
// @description Type "Bearer" followed by a space and JWT token.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/rest"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output"
|
||||||
|
"github.com/ocdp/cluster-service/internal/bootstrap"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/password"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("🚀 Starting OCDP Backend (Hexagonal Architecture)")
|
||||||
|
|
||||||
|
// ===== 1. 读取配置 =====
|
||||||
|
config := loadConfig()
|
||||||
|
log.Printf("📝 Configuration: mode=%s, port=%s", config.AdapterMode, config.Port)
|
||||||
|
|
||||||
|
// ===== 2. 创建加密器(用于敏感数据加密存储) =====
|
||||||
|
encryptor := crypto.NewAESEncryptor(config.EncryptionKey)
|
||||||
|
log.Println("✅ Encryption enabled for sensitive data")
|
||||||
|
|
||||||
|
// ===== 3. 创建 Output Adapters(通过工厂) =====
|
||||||
|
factory := output.NewAdapterFactory(
|
||||||
|
output.AdapterMode(config.AdapterMode),
|
||||||
|
encryptor,
|
||||||
|
config.DatabaseURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
repos, err := factory.CreateAllRepositories()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Failed to create adapters: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("✅ Output Adapters initialized (mode: %s)", config.AdapterMode)
|
||||||
|
|
||||||
|
// ===== 4. 创建工具包实例 =====
|
||||||
|
passwordHasher := password.NewHasher()
|
||||||
|
tokenGenerator := jwt.NewJWTManager(config.JWTSecret)
|
||||||
|
log.Println("✅ Utilities initialized")
|
||||||
|
|
||||||
|
// ===== 5. 创建 Domain Services =====
|
||||||
|
authService := service.NewAuthService(
|
||||||
|
repos.UserRepo,
|
||||||
|
passwordHasher,
|
||||||
|
tokenGenerator,
|
||||||
|
)
|
||||||
|
|
||||||
|
clusterService := service.NewClusterService(
|
||||||
|
repos.ClusterRepo,
|
||||||
|
)
|
||||||
|
|
||||||
|
registryService := service.NewRegistryService(
|
||||||
|
repos.RegistryRepo,
|
||||||
|
repos.OCIClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
artifactService := service.NewArtifactService(
|
||||||
|
repos.RegistryRepo,
|
||||||
|
repos.OCIClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
instanceService := service.NewInstanceService(
|
||||||
|
repos.InstanceRepo,
|
||||||
|
repos.ClusterRepo,
|
||||||
|
repos.RegistryRepo,
|
||||||
|
repos.HelmClient,
|
||||||
|
repos.OCIClient,
|
||||||
|
repos.EntryClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
monitoringService := service.NewMonitoringService(
|
||||||
|
repos.ClusterRepo,
|
||||||
|
repos.MetricsClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.Println("✅ Domain Services initialized")
|
||||||
|
|
||||||
|
// ===== 6. 加载并执行 Bootstrap 预注入 =====
|
||||||
|
bootstrapConfig, err := bootstrap.LoadBootstrapConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err)
|
||||||
|
// 使用默认配置
|
||||||
|
bootstrapConfig = bootstrap.GetDefaultBootstrapConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
seeder := bootstrap.NewSeeder(repos, passwordHasher, bootstrapConfig)
|
||||||
|
if err := seeder.SeedAll(context.Background()); err != nil {
|
||||||
|
log.Printf("⚠️ Warning: Failed to seed data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7. 创建 Input Adapters (REST Handlers) =====
|
||||||
|
authHandler := rest.NewAuthHandler(authService)
|
||||||
|
clusterHandler := rest.NewClusterHandler(clusterService)
|
||||||
|
registryHandler := rest.NewRegistryHandler(registryService)
|
||||||
|
artifactHandler := rest.NewArtifactHandler(artifactService)
|
||||||
|
instanceHandler := rest.NewInstanceHandler(instanceService)
|
||||||
|
monitoringHandler := rest.NewMonitoringHandler(monitoringService)
|
||||||
|
swaggerHandler := rest.NewSwaggerHandler()
|
||||||
|
|
||||||
|
log.Println("✅ Input Adapters (REST handlers) initialized")
|
||||||
|
|
||||||
|
// ===== 8. 设置路由 =====
|
||||||
|
router := setupRouter(
|
||||||
|
authHandler,
|
||||||
|
clusterHandler,
|
||||||
|
registryHandler,
|
||||||
|
artifactHandler,
|
||||||
|
instanceHandler,
|
||||||
|
monitoringHandler,
|
||||||
|
swaggerHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 9. 启动服务器 =====
|
||||||
|
addr := fmt.Sprintf(":%s", config.Port)
|
||||||
|
log.Printf("🌐 Server starting on %s", addr)
|
||||||
|
log.Println("")
|
||||||
|
log.Println("📍 Available endpoints:")
|
||||||
|
log.Printf(" - REST API: http://localhost:%s/api/v1", config.Port)
|
||||||
|
log.Printf(" - Swagger UI: http://localhost:%s/api/docs", config.Port)
|
||||||
|
log.Printf(" - OpenAPI Spec: http://localhost:%s/api/docs/openapi.yaml", config.Port)
|
||||||
|
log.Printf(" - Health: http://localhost:%s/health", config.Port)
|
||||||
|
log.Println("")
|
||||||
|
log.Println("✨ Press Ctrl+C to stop")
|
||||||
|
log.Println("")
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, router); err != nil {
|
||||||
|
log.Fatalf("❌ Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config 应用配置
|
||||||
|
type Config struct {
|
||||||
|
AdapterMode string
|
||||||
|
Port string
|
||||||
|
JWTSecret string
|
||||||
|
EncryptionKey string
|
||||||
|
DatabaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig 加载配置
|
||||||
|
func loadConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||||
|
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
|
||||||
|
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv 获取环境变量,如果不存在则返回默认值
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRouter 设置路由
|
||||||
|
func setupRouter(
|
||||||
|
authHandler *rest.AuthHandler,
|
||||||
|
clusterHandler *rest.ClusterHandler,
|
||||||
|
registryHandler *rest.RegistryHandler,
|
||||||
|
artifactHandler *rest.ArtifactHandler,
|
||||||
|
instanceHandler *rest.InstanceHandler,
|
||||||
|
monitoringHandler *rest.MonitoringHandler,
|
||||||
|
swaggerHandler *rest.SwaggerHandler,
|
||||||
|
) *mux.Router {
|
||||||
|
router := mux.NewRouter().StrictSlash(true)
|
||||||
|
|
||||||
|
// 全局中间件
|
||||||
|
router.Use(loggingMiddleware)
|
||||||
|
router.Use(corsMiddleware)
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"healthy"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Swagger UI =====
|
||||||
|
router.HandleFunc("/api/docs", swaggerHandler.ServeSwaggerUI).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/docs/assets/swagger-ui.css", swaggerHandler.ServeSwaggerCSS).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/docs/assets/swagger-ui-bundle.js", swaggerHandler.ServeSwaggerBundle).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/docs/assets/swagger-ui-standalone-preset.js", swaggerHandler.ServeSwaggerStandalonePreset).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/api/docs/openapi.yaml", swaggerHandler.ServeOpenAPISpec).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// API v1
|
||||||
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
|
// ===== 认证路由 =====
|
||||||
|
api.HandleFunc("/auth/register", authHandler.Register)
|
||||||
|
api.HandleFunc("/auth/login", authHandler.Login)
|
||||||
|
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
|
||||||
|
|
||||||
|
// ===== 集群路由 =====
|
||||||
|
api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
|
||||||
|
api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// ===== Registry 路由 =====
|
||||||
|
api.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
|
||||||
|
api.HandleFunc("/registries", registryHandler.GetAllRegistries).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/registries/{registry_id}", registryHandler.GetRegistry).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/registries/{registry_id}", registryHandler.UpdateRegistry).Methods(http.MethodPut)
|
||||||
|
api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
|
||||||
|
api.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// ===== Artifact 路由 =====
|
||||||
|
api.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}", artifactHandler.GetArtifact).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// ===== Instance 路由 =====
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.ListInstances).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.GetInstance).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.UpdateInstance).Methods(http.MethodPut)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.DeleteInstance).Methods(http.MethodDelete)
|
||||||
|
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// ===== Monitoring 路由 =====
|
||||||
|
api.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
|
||||||
|
api.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// 处理 MethodNotAllowed 错误(OPTIONS 请求会触发)
|
||||||
|
router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
// CORS 预检已在中间件处理,这里直接返回
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware 日志中间件
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s %v", r.Method, r.RequestURI, r.RemoteAddr, time.Since(start))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware CORS 中间件
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 设置 CORS 头
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
origin = "*"
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
// 处理 OPTIONS 预检请求
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
31
backend/config/bootstrap.example.json
Normal file
31
backend/config/bootstrap.example.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "change-me-in-production",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"registries": [
|
||||||
|
{
|
||||||
|
"name": "my-harbor",
|
||||||
|
"url": "https://harbor.example.com",
|
||||||
|
"description": "Harbor Registry",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "change-me",
|
||||||
|
"insecure": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"name": "my-cluster",
|
||||||
|
"host": "https://kubernetes.example.com:6443",
|
||||||
|
"description": "Production Kubernetes Cluster",
|
||||||
|
"caData": "LS0tLS1CRUdJTi...(base64-encoded-ca-cert)...",
|
||||||
|
"certData": "LS0tLS1CRUdJTi...(base64-encoded-client-cert)...",
|
||||||
|
"keyData": "LS0tLS1CRUdJTi...(base64-encoded-client-key)..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
157
backend/docker-compose.yml
Normal file
157
backend/docker-compose.yml
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# ==================================================
|
||||||
|
# OCDP Backend - Docker Compose 统一配置
|
||||||
|
# ==================================================
|
||||||
|
# 使用方式:
|
||||||
|
#
|
||||||
|
# 1. 只启动依赖服务 (PostgreSQL) - 开发模式
|
||||||
|
# docker compose up -d
|
||||||
|
# 然后在本地运行: go run cmd/api/main.go
|
||||||
|
#
|
||||||
|
# 2. 启动完整服务 (PostgreSQL + Backend)
|
||||||
|
# docker compose --profile backend up -d
|
||||||
|
#
|
||||||
|
# 3. 启动 Mock 模式 (无需数据库)
|
||||||
|
# docker compose --profile mock up -d
|
||||||
|
#
|
||||||
|
# 4. 启动数据库管理工具 (可选)
|
||||||
|
# docker compose --profile tools up -d
|
||||||
|
#
|
||||||
|
# 停止服务:
|
||||||
|
# docker compose down
|
||||||
|
#
|
||||||
|
# 查看日志:
|
||||||
|
# docker compose logs -f
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ==================================================
|
||||||
|
# PostgreSQL 数据库 (默认启动)
|
||||||
|
# ==================================================
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: ocdp-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-ocdp}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-ocdp}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# Backend - 真实模式 (连接 PostgreSQL)
|
||||||
|
# 使用: docker compose --profile backend up -d
|
||||||
|
# ==================================================
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||||
|
dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile}
|
||||||
|
image: ocdp-backend:latest
|
||||||
|
container_name: ocdp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||||
|
PORT: 8080
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
- ./data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# Backend - Mock 模式 (无需数据库,适合快速测试)
|
||||||
|
# 使用: docker compose --profile mock up -d
|
||||||
|
# ==================================================
|
||||||
|
backend-mock:
|
||||||
|
build:
|
||||||
|
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||||
|
dockerfile: ${BACKEND_MOCK_BUILD_DOCKERFILE:-Dockerfile.mock}
|
||||||
|
container_name: ocdp-backend-mock
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ADAPTER_MODE: mock
|
||||||
|
PORT: 8080
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long}
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8080}:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
profiles:
|
||||||
|
- mock
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# pgAdmin - 数据库管理界面 (可选)
|
||||||
|
# 使用: docker compose --profile tools up -d
|
||||||
|
# ==================================================
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:latest
|
||||||
|
container_name: ocdp-pgadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local}
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||||
|
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||||
|
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
|
||||||
|
ports:
|
||||||
|
- "${PGADMIN_PORT:-5050}:80"
|
||||||
|
volumes:
|
||||||
|
- pgadmin_data:/var/lib/pgadmin
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- tools
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 网络配置
|
||||||
|
# ==================================================
|
||||||
|
networks:
|
||||||
|
ocdp-network:
|
||||||
|
driver: bridge
|
||||||
|
name: ocdp-network
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 数据卷配置
|
||||||
|
# ==================================================
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: ocdp-postgres-data
|
||||||
|
pgadmin_data:
|
||||||
|
name: ocdp-pgadmin-data
|
||||||
1802
backend/docs/api-and-test.md
Normal file
1802
backend/docs/api-and-test.md
Normal file
File diff suppressed because it is too large
Load Diff
1305
backend/docs/architecture.md
Normal file
1305
backend/docs/architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1546
backend/docs/deployment.md
Normal file
1546
backend/docs/deployment.md
Normal file
File diff suppressed because it is too large
Load Diff
8
backend/docs/embed.go
Normal file
8
backend/docs/embed.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed openapi.yaml
|
||||||
|
OpenAPISpec []byte
|
||||||
|
)
|
||||||
2055
backend/docs/openapi.json
Normal file
2055
backend/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
1367
backend/docs/openapi.yaml
Normal file
1367
backend/docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
43
backend/env.example
Normal file
43
backend/env.example
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# ==================================================
|
||||||
|
# OCDP Backend - 环境变量配置示例
|
||||||
|
# ==================================================
|
||||||
|
# 使用方式:
|
||||||
|
# 1. 复制此文件为 .env: cp env.example .env
|
||||||
|
# 2. 修改相应的配置值
|
||||||
|
# 3. 启动服务: docker compose up
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 应用配置
|
||||||
|
# ==================================================
|
||||||
|
# 适配器模式: mock (开发调试) 或留空/任意值 (真实模式,连接 PostgreSQL)
|
||||||
|
# ADAPTER_MODE=mock
|
||||||
|
ADAPTER_MODE=
|
||||||
|
|
||||||
|
# 后端服务端口
|
||||||
|
BACKEND_PORT=8080
|
||||||
|
|
||||||
|
# JWT 密钥 (生产环境必须修改)
|
||||||
|
JWT_SECRET=change-me-in-production-use-strong-secret
|
||||||
|
|
||||||
|
# 加密密钥 (必须是 32 字节,生产环境必须修改)
|
||||||
|
ENCRYPTION_KEY=change-me-32-bytes-long-key-here
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# PostgreSQL 数据库配置
|
||||||
|
# ==================================================
|
||||||
|
POSTGRES_DB=ocdp
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# 完整的数据库连接 URL (可选,会自动从上面的配置生成)
|
||||||
|
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# pgAdmin 配置 (可选)
|
||||||
|
# ==================================================
|
||||||
|
PGADMIN_EMAIL=admin@ocdp.local
|
||||||
|
PGADMIN_PASSWORD=admin
|
||||||
|
PGADMIN_PORT=5050
|
||||||
|
|
||||||
127
backend/go.mod
Normal file
127
backend/go.mod
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
module github.com/ocdp/cluster-service
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
helm.sh/helm/v3 v3.19.0
|
||||||
|
k8s.io/api v0.34.1
|
||||||
|
k8s.io/apimachinery v0.34.1
|
||||||
|
k8s.io/client-go v0.34.1
|
||||||
|
k8s.io/metrics v0.34.1
|
||||||
|
oras.land/oras-go/v2 v2.6.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.1 // indirect
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 // indirect
|
||||||
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||||
|
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
|
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||||
|
github.com/chai2010/gettext-go v1.0.2 // indirect
|
||||||
|
github.com/containerd/containerd v1.7.28 // indirect
|
||||||
|
github.com/containerd/errdefs v0.3.0 // indirect
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
|
github.com/evanphx/json-patch v5.9.11+incompatible // indirect
|
||||||
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
|
||||||
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/go-errors/errors v1.4.2 // indirect
|
||||||
|
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/btree v1.1.3 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||||
|
github.com/gosuri/uitable v0.0.4 // indirect
|
||||||
|
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
|
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/moby/spdystream v0.5.0 // indirect
|
||||||
|
github.com/moby/term v0.5.2 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/rubenv/sql-migrate v1.8.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/xlab/treeprint v1.2.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.45.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/term v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
golang.org/x/time v0.12.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
|
google.golang.org/grpc v1.72.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
k8s.io/apiextensions-apiserver v0.34.0 // indirect
|
||||||
|
k8s.io/apiserver v0.34.0 // indirect
|
||||||
|
k8s.io/cli-runtime v0.34.0 // indirect
|
||||||
|
k8s.io/component-base v0.34.0 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
|
k8s.io/kubectl v0.34.0 // indirect
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||||
|
sigs.k8s.io/kustomize/api v0.20.1 // indirect
|
||||||
|
sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
|
)
|
||||||
452
backend/go.sum
Normal file
452
backend/go.sum
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||||
|
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||||
|
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||||
|
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||||
|
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||||
|
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||||
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
|
||||||
|
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk=
|
||||||
|
github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
|
||||||
|
github.com/containerd/containerd v1.7.28 h1:Nsgm1AtcmEh4AHAJ4gGlNSaKgXiNccU270Dnf81FQ3c=
|
||||||
|
github.com/containerd/containerd v1.7.28/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs=
|
||||||
|
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
|
||||||
|
github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||||
|
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM=
|
||||||
|
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
|
||||||
|
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
|
||||||
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
||||||
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
||||||
|
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
|
||||||
|
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
|
||||||
|
github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||||
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
|
||||||
|
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
|
||||||
|
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
|
||||||
|
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
|
||||||
|
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.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/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||||
|
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
|
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||||
|
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
|
||||||
|
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
|
||||||
|
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw=
|
||||||
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||||
|
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
|
||||||
|
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||||
|
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||||
|
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||||
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
|
||||||
|
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
|
||||||
|
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||||
|
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
|
||||||
|
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
|
||||||
|
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
|
||||||
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||||
|
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho=
|
||||||
|
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U=
|
||||||
|
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc=
|
||||||
|
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o=
|
||||||
|
github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||||
|
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||||
|
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||||
|
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w=
|
||||||
|
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk=
|
||||||
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4=
|
||||||
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s=
|
||||||
|
go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk=
|
||||||
|
go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||||
|
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
|
||||||
|
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
||||||
|
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=
|
||||||
|
helm.sh/helm/v3 v3.19.0/go.mod h1:Lk/SfzN0w3a3C3o+TdAKrLwJ0wcZ//t1/SDXAvfgDdc=
|
||||||
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
|
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||||
|
k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc=
|
||||||
|
k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0=
|
||||||
|
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||||
|
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
|
k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg=
|
||||||
|
k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ=
|
||||||
|
k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw=
|
||||||
|
k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8=
|
||||||
|
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||||
|
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||||
|
k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8=
|
||||||
|
k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||||
|
k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs=
|
||||||
|
k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4=
|
||||||
|
k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE=
|
||||||
|
k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
|
||||||
|
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
|
sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I=
|
||||||
|
sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM=
|
||||||
|
sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78=
|
||||||
|
sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po=
|
||||||
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
44
backend/internal/adapter/input/http/dto/artifact_dto.go
Normal file
44
backend/internal/adapter/input/http/dto/artifact_dto.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// RepositoryListResponse Repository 列表响应
|
||||||
|
type RepositoryListResponse struct {
|
||||||
|
RegistryID string `json:"registryId"`
|
||||||
|
RegistryURL string `json:"registryUrl"`
|
||||||
|
Repositories []string `json:"repositories"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
||||||
|
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
||||||
|
Message string `json:"message,omitempty"` // User-friendly message
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
|
||||||
|
type ArtifactResponse struct {
|
||||||
|
RepositoryName string `json:"repositoryName"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Type string `json:"type"` // chart | image | other
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagResponse Tag 响应(前端期望的扁平化结构)
|
||||||
|
type TagResponse struct {
|
||||||
|
RepositoryName string `json:"repositoryName"` // Repository name
|
||||||
|
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
||||||
|
Type string `json:"type"` // Artifact type: chart, image, other
|
||||||
|
MediaType string `json:"mediaType,omitempty"`
|
||||||
|
Size int64 `json:"size"` // Artifact size (bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
|
||||||
|
type ArtifactListResponse struct {
|
||||||
|
RepositoryName string `json:"repositoryName"`
|
||||||
|
Artifacts []*ArtifactResponse `json:"artifacts"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValuesSchemaResponse Values Schema 响应
|
||||||
|
type ValuesSchemaResponse struct {
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
35
backend/internal/adapter/input/http/dto/auth_dto.go
Normal file
35
backend/internal/adapter/input/http/dto/auth_dto.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// RegisterRequest 用户注册请求
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest 用户登录请求
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokenRequest 刷新 Token 请求
|
||||||
|
type RefreshTokenRequest struct {
|
||||||
|
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse 认证响应
|
||||||
|
type AuthResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserResponse 用户信息响应
|
||||||
|
type UserResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
82
backend/internal/adapter/input/http/dto/cluster_dto.go
Normal file
82
backend/internal/adapter/input/http/dto/cluster_dto.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreateClusterRequest 创建集群请求
|
||||||
|
type CreateClusterRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Host string `json:"host" binding:"required"`
|
||||||
|
CAData string `json:"caData"`
|
||||||
|
CADataAlt string `json:"ca_data"`
|
||||||
|
CertData string `json:"certData"`
|
||||||
|
CertDataAlt string `json:"cert_data"`
|
||||||
|
KeyData string `json:"keyData"`
|
||||||
|
KeyDataAlt string `json:"key_data"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateClusterRequest 更新集群请求
|
||||||
|
type UpdateClusterRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
CAData string `json:"caData"`
|
||||||
|
CADataAlt string `json:"ca_data"`
|
||||||
|
CertData string `json:"certData"`
|
||||||
|
CertDataAlt string `json:"cert_data"`
|
||||||
|
KeyData string `json:"keyData"`
|
||||||
|
KeyDataAlt string `json:"key_data"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
|
func (r *CreateClusterRequest) Normalize() {
|
||||||
|
if r.CAData == "" {
|
||||||
|
r.CAData = r.CADataAlt
|
||||||
|
}
|
||||||
|
if r.CertData == "" {
|
||||||
|
r.CertData = r.CertDataAlt
|
||||||
|
}
|
||||||
|
if r.KeyData == "" {
|
||||||
|
r.KeyData = r.KeyDataAlt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
|
func (r *UpdateClusterRequest) Normalize() {
|
||||||
|
if r.CAData == "" {
|
||||||
|
r.CAData = r.CADataAlt
|
||||||
|
}
|
||||||
|
if r.CertData == "" {
|
||||||
|
r.CertData = r.CertDataAlt
|
||||||
|
}
|
||||||
|
if r.KeyData == "" {
|
||||||
|
r.KeyData = r.KeyDataAlt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterResponse 集群响应(敏感数据已脱敏)
|
||||||
|
type ClusterResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
|
||||||
|
HasCAData bool `json:"hasCaData"`
|
||||||
|
HasCertData bool `json:"hasCertData"`
|
||||||
|
HasKeyData bool `json:"hasKeyData"`
|
||||||
|
HasToken bool `json:"hasToken"`
|
||||||
|
// 脱敏数据(仅用于前端显示,实际值为掩码)
|
||||||
|
CAData string `json:"caData,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
CertData string `json:"certData,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
KeyData string `json:"keyData,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
Token string `json:"token,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterHealthResponse 集群健康状态响应
|
||||||
|
type ClusterHealthResponse struct {
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
63
backend/internal/adapter/input/http/dto/converter.go
Normal file
63
backend/internal/adapter/input/http/dto/converter.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToRegistryResponse 转换 Registry 实体为响应 DTO(脱敏)
|
||||||
|
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
||||||
|
response := &RegistryResponse{
|
||||||
|
ID: registry.ID,
|
||||||
|
Name: registry.Name,
|
||||||
|
URL: registry.URL,
|
||||||
|
Description: registry.Description,
|
||||||
|
Username: registry.Username,
|
||||||
|
Insecure: registry.Insecure,
|
||||||
|
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脱敏处理密码
|
||||||
|
if registry.Password != "" {
|
||||||
|
response.HasPassword = true
|
||||||
|
response.Password = crypto.MaskSensitiveData(registry.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToClusterResponse 转换 Cluster 实体为响应 DTO(脱敏)
|
||||||
|
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
||||||
|
response := &ClusterResponse{
|
||||||
|
ID: cluster.ID,
|
||||||
|
Name: cluster.Name,
|
||||||
|
Host: cluster.Host,
|
||||||
|
Description: cluster.Description,
|
||||||
|
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.HasCertData = cluster.CertData != ""
|
||||||
|
response.HasKeyData = cluster.KeyData != ""
|
||||||
|
response.HasToken = cluster.Token != ""
|
||||||
|
|
||||||
|
// 脱敏处理敏感数据(仅显示掩码)
|
||||||
|
if cluster.CAData != "" {
|
||||||
|
response.CAData = crypto.MaskSensitiveData(cluster.CAData)
|
||||||
|
}
|
||||||
|
if cluster.CertData != "" {
|
||||||
|
response.CertData = crypto.MaskSensitiveData(cluster.CertData)
|
||||||
|
}
|
||||||
|
if cluster.KeyData != "" {
|
||||||
|
response.KeyData = crypto.MaskSensitiveData(cluster.KeyData)
|
||||||
|
}
|
||||||
|
if cluster.Token != "" {
|
||||||
|
response.Token = crypto.MaskSensitiveData(cluster.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
15
backend/internal/adapter/input/http/dto/error_dto.go
Normal file
15
backend/internal/adapter/input/http/dto/error_dto.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ErrorResponse 错误响应
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuccessResponse 成功响应
|
||||||
|
type SuccessResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
133
backend/internal/adapter/input/http/dto/instance_dto.go
Normal file
133
backend/internal/adapter/input/http/dto/instance_dto.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreateInstanceRequest 创建实例请求
|
||||||
|
type CreateInstanceRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Namespace string `json:"namespace" binding:"required"`
|
||||||
|
RegistryID string `json:"registryId" binding:"required"`
|
||||||
|
RegistryIDAlt string `json:"registry_id"`
|
||||||
|
Repository string `json:"repository" binding:"required"`
|
||||||
|
Tag string `json:"tag" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Values map[string]interface{} `json:"values"`
|
||||||
|
ValuesYAML string `json:"valuesYaml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstanceRequest 更新实例请求
|
||||||
|
type UpdateInstanceRequest struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Values map[string]interface{} `json:"values"`
|
||||||
|
ValuesYAML string `json:"valuesYaml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
|
func (r *CreateInstanceRequest) Normalize() {
|
||||||
|
if r.RegistryID == "" {
|
||||||
|
r.RegistryID = r.RegistryIDAlt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RollbackInstanceRequest 回滚实例请求
|
||||||
|
type RollbackInstanceRequest struct {
|
||||||
|
Revision int `json:"revision" binding:"required"`
|
||||||
|
Wait bool `json:"wait"`
|
||||||
|
Timeout int `json:"timeout"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInstanceRequest 删除实例请求
|
||||||
|
type DeleteInstanceRequest struct {
|
||||||
|
KeepHistory bool `json:"keepHistory"`
|
||||||
|
Timeout int `json:"timeout"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceResponse 实例响应
|
||||||
|
type InstanceResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ClusterID string `json:"clusterId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
RegistryID string `json:"registryId"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Chart string `json:"chart"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusReason string `json:"statusReason,omitempty"`
|
||||||
|
LastOperation string `json:"lastOperation,omitempty"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Values map[string]interface{} `json:"values,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceStatusResponse 实例状态响应
|
||||||
|
type InstanceStatusResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Chart string `json:"chart"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseHistoryResponse Release 历史响应
|
||||||
|
type ReleaseHistoryResponse struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Updated string `json:"updated"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Chart string `json:"chart"`
|
||||||
|
AppVersion string `json:"appVersion"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceListResponse 实例列表响应
|
||||||
|
type InstanceListResponse struct {
|
||||||
|
Instances []*InstanceResponse `json:"instances"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryPortResponse Service 端口响应
|
||||||
|
type InstanceEntryPortResponse struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Port int32 `json:"port"`
|
||||||
|
TargetPort string `json:"targetPort,omitempty"`
|
||||||
|
NodePort int32 `json:"nodePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryPathResponse Ingress path 响应
|
||||||
|
type InstanceEntryPathResponse struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ServiceName string `json:"serviceName,omitempty"`
|
||||||
|
ServicePort string `json:"servicePort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryHostResponse Ingress host 响应
|
||||||
|
type InstanceEntryHostResponse struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Paths []InstanceEntryPathResponse `json:"paths,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryTLSResponse Ingress TLS 响应
|
||||||
|
type InstanceEntryTLSResponse struct {
|
||||||
|
Hosts []string `json:"hosts,omitempty"`
|
||||||
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryResponse 实例入口响应
|
||||||
|
type InstanceEntryResponse struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
ClusterIP string `json:"clusterIP,omitempty"`
|
||||||
|
ExternalIPs []string `json:"externalIPs,omitempty"`
|
||||||
|
LoadBalancerIngress []string `json:"loadBalancerIngress,omitempty"`
|
||||||
|
Ports []InstanceEntryPortResponse `json:"ports,omitempty"`
|
||||||
|
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
|
||||||
|
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
|
||||||
|
}
|
||||||
143
backend/internal/adapter/input/http/dto/monitoring_dto.go
Normal file
143
backend/internal/adapter/input/http/dto/monitoring_dto.go
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterMetricsResponse 集群监控响应
|
||||||
|
type ClusterMetricsResponse struct {
|
||||||
|
ClusterID string `json:"clusterId"`
|
||||||
|
ClusterName string `json:"clusterName"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
NodeCount int `json:"nodeCount"`
|
||||||
|
PodCount int `json:"podCount"`
|
||||||
|
LastCheck time.Time `json:"lastCheck"`
|
||||||
|
TotalCPU string `json:"totalCpu"`
|
||||||
|
TotalMemory string `json:"totalMemory"`
|
||||||
|
TotalGPU int `json:"totalGpu"`
|
||||||
|
UsedCPU string `json:"usedCpu"`
|
||||||
|
UsedMemory string `json:"usedMemory"`
|
||||||
|
UsedGPU int `json:"usedGpu"`
|
||||||
|
CPUUsage float64 `json:"cpuUsage"`
|
||||||
|
MemoryUsage float64 `json:"memoryUsage"`
|
||||||
|
GPUUsage float64 `json:"gpuUsage"`
|
||||||
|
MaxNodeCPU string `json:"maxNodeCpu"`
|
||||||
|
MaxNodeMemory string `json:"maxNodeMemory"`
|
||||||
|
MaxNodeGPU int `json:"maxNodeGpu"`
|
||||||
|
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
||||||
|
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
||||||
|
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
||||||
|
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeMetricsResponse 节点监控响应
|
||||||
|
type NodeMetricsResponse struct {
|
||||||
|
NodeName string `json:"nodeName"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Age string `json:"age"`
|
||||||
|
PodCount int `json:"podCount"`
|
||||||
|
CPUCapacity string `json:"cpuCapacity"`
|
||||||
|
CPUAllocatable string `json:"cpuAllocatable"`
|
||||||
|
CPUUsage string `json:"cpuUsage"`
|
||||||
|
CPUPercent float64 `json:"cpuPercent"`
|
||||||
|
MemoryCapacity string `json:"memoryCapacity"`
|
||||||
|
MemoryAllocatable string `json:"memoryAllocatable"`
|
||||||
|
MemoryUsage string `json:"memoryUsage"`
|
||||||
|
MemoryPercent float64 `json:"memoryPercent"`
|
||||||
|
GPUCapacity int `json:"gpuCapacity"`
|
||||||
|
GPUUsage int `json:"gpuUsage"`
|
||||||
|
GPUPercent float64 `json:"gpuPercent"`
|
||||||
|
GPUType string `json:"gpuType,omitempty"`
|
||||||
|
OSImage string `json:"osImage,omitempty"`
|
||||||
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||||
|
ContainerRuntime string `json:"containerRuntime,omitempty"`
|
||||||
|
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitoringSummaryResponse 监控汇总响应
|
||||||
|
type MonitoringSummaryResponse struct {
|
||||||
|
TotalClusters int `json:"totalClusters"`
|
||||||
|
HealthyClusters int `json:"healthyClusters"`
|
||||||
|
WarningClusters int `json:"warningClusters"`
|
||||||
|
ErrorClusters int `json:"errorClusters"`
|
||||||
|
TotalNodes int `json:"totalNodes"`
|
||||||
|
TotalPods int `json:"totalPods"`
|
||||||
|
LastUpdate time.Time `json:"lastUpdate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToClusterMetricsResponse 转换为响应
|
||||||
|
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
|
||||||
|
resp := &ClusterMetricsResponse{
|
||||||
|
ClusterID: m.ClusterID,
|
||||||
|
ClusterName: m.ClusterName,
|
||||||
|
Status: m.Status,
|
||||||
|
Uptime: m.Uptime,
|
||||||
|
NodeCount: m.NodeCount,
|
||||||
|
PodCount: m.PodCount,
|
||||||
|
LastCheck: m.LastCheck,
|
||||||
|
TotalCPU: m.TotalCPU,
|
||||||
|
TotalMemory: m.TotalMemory,
|
||||||
|
TotalGPU: m.TotalGPU,
|
||||||
|
UsedCPU: m.UsedCPU,
|
||||||
|
UsedMemory: m.UsedMemory,
|
||||||
|
UsedGPU: m.UsedGPU,
|
||||||
|
CPUUsage: m.CPUUsage,
|
||||||
|
MemoryUsage: m.MemoryUsage,
|
||||||
|
GPUUsage: m.GPUUsage,
|
||||||
|
MaxNodeCPU: m.MaxNodeCPU,
|
||||||
|
MaxNodeMemory: m.MaxNodeMemory,
|
||||||
|
MaxNodeGPU: m.MaxNodeGPU,
|
||||||
|
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
||||||
|
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
||||||
|
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Nodes) > 0 {
|
||||||
|
resp.Nodes = make([]NodeMetricsResponse, len(m.Nodes))
|
||||||
|
for i, node := range m.Nodes {
|
||||||
|
resp.Nodes[i] = NodeMetricsResponse{
|
||||||
|
NodeName: node.NodeName,
|
||||||
|
Status: node.Status,
|
||||||
|
Role: node.Role,
|
||||||
|
Age: node.Age,
|
||||||
|
PodCount: node.PodCount,
|
||||||
|
CPUCapacity: node.CPUCapacity,
|
||||||
|
CPUAllocatable: node.CPUAllocatable,
|
||||||
|
CPUUsage: node.CPUUsage,
|
||||||
|
CPUPercent: node.CPUPercent,
|
||||||
|
MemoryCapacity: node.MemoryCapacity,
|
||||||
|
MemoryAllocatable: node.MemoryAllocatable,
|
||||||
|
MemoryUsage: node.MemoryUsage,
|
||||||
|
MemoryPercent: node.MemoryPercent,
|
||||||
|
GPUCapacity: node.GPUCapacity,
|
||||||
|
GPUUsage: node.GPUUsage,
|
||||||
|
GPUPercent: node.GPUPercent,
|
||||||
|
GPUType: node.GPUType,
|
||||||
|
OSImage: node.OSImage,
|
||||||
|
KernelVersion: node.KernelVersion,
|
||||||
|
ContainerRuntime: node.ContainerRuntime,
|
||||||
|
KubeletVersion: node.KubeletVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToMonitoringSummaryResponse 转换为汇总响应
|
||||||
|
func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummaryResponse {
|
||||||
|
return &MonitoringSummaryResponse{
|
||||||
|
TotalClusters: s.TotalClusters,
|
||||||
|
HealthyClusters: s.HealthyClusters,
|
||||||
|
WarningClusters: s.WarningClusters,
|
||||||
|
ErrorClusters: s.ErrorClusters,
|
||||||
|
TotalNodes: s.TotalNodes,
|
||||||
|
TotalPods: s.TotalPods,
|
||||||
|
LastUpdate: s.LastUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
backend/internal/adapter/input/http/dto/registry_dto.go
Normal file
42
backend/internal/adapter/input/http/dto/registry_dto.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreateRegistryRequest 创建 Registry 请求
|
||||||
|
type CreateRegistryRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRegistryRequest 更新 Registry 请求
|
||||||
|
type UpdateRegistryRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryResponse Registry 响应(敏感数据已脱敏)
|
||||||
|
type RegistryResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
||||||
|
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryHealthResponse Registry 健康状态响应
|
||||||
|
type RegistryHealthResponse struct {
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
193
backend/internal/adapter/input/http/rest/artifact_handler.go
Normal file
193
backend/internal/adapter/input/http/rest/artifact_handler.go
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactHandler Artifact Handler
|
||||||
|
type ArtifactHandler struct {
|
||||||
|
artifactService *service.ArtifactService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifactHandler 创建 Artifact Handler
|
||||||
|
func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandler {
|
||||||
|
return &ArtifactHandler{
|
||||||
|
artifactService: artifactService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepositories 列出 Registry 中的所有 repositories
|
||||||
|
// @Summary 列出 Registry 中的所有 Repositories
|
||||||
|
// @Description 列出指定 Registry 中的所有 Repository
|
||||||
|
// @Tags Artifacts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Success 200 {object} dto.RepositoryListResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id}/repositories [get]
|
||||||
|
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
|
||||||
|
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get registry info for URL
|
||||||
|
registry, err := h.artifactService.GetRegistry(r.Context(), registryID)
|
||||||
|
registryURL := ""
|
||||||
|
if err == nil && registry != nil {
|
||||||
|
registryURL = registry.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine source and message based on repository count
|
||||||
|
source := "catalog"
|
||||||
|
catalogSupported := true
|
||||||
|
message := ""
|
||||||
|
|
||||||
|
if len(repositories) == 0 {
|
||||||
|
source = "unavailable"
|
||||||
|
message = "No repositories found in this registry"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.RepositoryListResponse{
|
||||||
|
RegistryID: registryID,
|
||||||
|
RegistryURL: registryURL,
|
||||||
|
Repositories: repositories,
|
||||||
|
Total: len(repositories),
|
||||||
|
CatalogSupported: catalogSupported,
|
||||||
|
Source: source,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArtifacts 列出 repository 中的所有 artifacts(返回扁平化的 Tag 数组)
|
||||||
|
// @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, e.g. charts%2Fnginx)"
|
||||||
|
// @Param media_type query string false "过滤 Artifact 类型 (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) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
repositoryName := vars["repository_name"]
|
||||||
|
|
||||||
|
// 获取 mediaType 过滤参数(默认为 "all")
|
||||||
|
mediaTypeFilter := r.URL.Query().Get("media_type")
|
||||||
|
if mediaTypeFilter == "" {
|
||||||
|
mediaTypeFilter = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts, err := h.artifactService.ListArtifacts(r.Context(), registryID, repositoryName, mediaTypeFilter)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to list artifacts", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为前端期望的扁平化 Tag 数组
|
||||||
|
tagResponses := make([]*dto.TagResponse, 0, len(artifacts))
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
tagResponses = append(tagResponses, &dto.TagResponse{
|
||||||
|
RepositoryName: artifact.Repository,
|
||||||
|
Tag: artifact.Tag,
|
||||||
|
Type: string(artifact.Type),
|
||||||
|
MediaType: artifact.MediaType,
|
||||||
|
Size: artifact.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接返回数组,不包装
|
||||||
|
respondJSON(w, http.StatusOK, tagResponses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// @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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifactValuesSchema 获取 Helm Chart 的 values schema
|
||||||
|
// @Summary 获取 Helm Chart Values Schema
|
||||||
|
// @Description 获取 Helm Chart 的 values.schema.json (仅支持 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.ValuesSchemaResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-schema [get]
|
||||||
|
func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
repositoryName := vars["repository_name"]
|
||||||
|
reference := vars["reference"]
|
||||||
|
|
||||||
|
schema, err := h.artifactService.GetValuesSchema(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.ErrValuesSchemaNotFound):
|
||||||
|
respondError(w, http.StatusNotFound, "Values schema not found", err.Error())
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to get values schema", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.ValuesSchemaResponse{
|
||||||
|
Schema: schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
127
backend/internal/adapter/input/http/rest/auth_handler.go
Normal file
127
backend/internal/adapter/input/http/rest/auth_handler.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler 认证 Handler
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler 创建认证 Handler
|
||||||
|
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 用户注册
|
||||||
|
// @Summary 用户注册
|
||||||
|
// @Description 创建一个新的后台用户
|
||||||
|
// @Tags Auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.RegisterRequest true "注册信息"
|
||||||
|
// @Success 201 {object} dto.UserResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Router /auth/register [post]
|
||||||
|
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
user, err := h.authService.Register(r.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
response := &dto.UserResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录
|
||||||
|
// @Summary 用户登录
|
||||||
|
// @Description 使用用户名和密码获取访问令牌
|
||||||
|
// @Tags Auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.LoginRequest true "登录信息"
|
||||||
|
// @Success 200 {object} dto.AuthResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Router /auth/login [post]
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
// TODO: 从 token 解析用户信息或从服务获取
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
response := &dto.AuthResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
Username: req.Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken 刷新 Token
|
||||||
|
// @Summary 刷新访问令牌
|
||||||
|
// @Description 使用刷新令牌获取新的访问令牌
|
||||||
|
// @Tags Auth
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.RefreshTokenRequest true "刷新令牌"
|
||||||
|
// @Success 200 {object} dto.AuthResponse
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Router /auth/refresh [post]
|
||||||
|
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.RefreshTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
response := &dto.AuthResponse{
|
||||||
|
AccessToken: newAccessToken,
|
||||||
|
RefreshToken: req.RefreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
221
backend/internal/adapter/input/http/rest/cluster_handler.go
Normal file
221
backend/internal/adapter/input/http/rest/cluster_handler.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterHandler 集群 Handler
|
||||||
|
type ClusterHandler struct {
|
||||||
|
clusterService *service.ClusterService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterHandler 创建集群 Handler
|
||||||
|
func NewClusterHandler(clusterService *service.ClusterService) *ClusterHandler {
|
||||||
|
return &ClusterHandler{
|
||||||
|
clusterService: clusterService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCluster 创建集群
|
||||||
|
// @Summary 创建集群
|
||||||
|
// @Description 创建一个新的 Kubernetes 集群配置
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param request body dto.CreateClusterRequest true "集群信息"
|
||||||
|
// @Success 201 {object} dto.ClusterResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters [post]
|
||||||
|
func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.CreateClusterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Normalize()
|
||||||
|
|
||||||
|
// 创建实体
|
||||||
|
cluster := entity.NewCluster(req.Name, req.Host)
|
||||||
|
cluster.Description = req.Description
|
||||||
|
|
||||||
|
if req.CertData != "" && req.KeyData != "" {
|
||||||
|
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||||
|
} else if req.Token != "" {
|
||||||
|
cluster.SetTokenAuth(req.Token)
|
||||||
|
} else if os.Getenv("ADAPTER_MODE") == "mock" {
|
||||||
|
// Mock 模式:如果没有提供认证信息,使用默认的 Mock 证书
|
||||||
|
cluster.SetCertAuth(
|
||||||
|
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ==",
|
||||||
|
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=",
|
||||||
|
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.clusterService.CreateCluster(r.Context(), cluster); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to create cluster", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
response := h.toClusterResponse(cluster)
|
||||||
|
respondJSON(w, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCluster 获取集群详情
|
||||||
|
// @Summary 获取集群详情
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 200 {object} dto.ClusterResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id} [get]
|
||||||
|
func (h *ClusterHandler) GetCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := h.toClusterResponse(cluster)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllClusters 获取所有集群
|
||||||
|
// @Summary 列出所有集群
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} dto.ClusterResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters [get]
|
||||||
|
func (h *ClusterHandler) GetAllClusters(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clusters, err := h.clusterService.ListClusters(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to list clusters", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.ClusterResponse, 0, len(clusters))
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
responses = append(responses, h.toClusterResponse(cluster))
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCluster 更新集群
|
||||||
|
// @Summary 更新集群
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param request body dto.UpdateClusterRequest true "更新内容"
|
||||||
|
// @Success 200 {object} dto.ClusterResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id} [put]
|
||||||
|
func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
var req dto.UpdateClusterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Normalize()
|
||||||
|
|
||||||
|
// 获取现有集群
|
||||||
|
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
cluster.Update(req.Name, req.Host, req.Description)
|
||||||
|
|
||||||
|
if req.CertData != "" && req.KeyData != "" {
|
||||||
|
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||||
|
} else if req.Token != "" {
|
||||||
|
cluster.SetTokenAuth(req.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.clusterService.UpdateCluster(r.Context(), cluster); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to update cluster", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := h.toClusterResponse(cluster)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCluster 删除集群
|
||||||
|
// @Summary 删除集群
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 204 {string} string "No Content"
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id} [delete]
|
||||||
|
func (h *ClusterHandler) DeleteCluster(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
if err := h.clusterService.DeleteCluster(r.Context(), clusterID); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Failed to delete cluster", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterHealth 获取集群健康状态
|
||||||
|
// @Summary 获取集群健康状态
|
||||||
|
// @Tags Clusters
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 200 {object} dto.ClusterHealthResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/health [get]
|
||||||
|
func (h *ClusterHandler) GetClusterHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
// 检查集群是否存在
|
||||||
|
_, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 实现真实的健康检查
|
||||||
|
response := &dto.ClusterHealthResponse{
|
||||||
|
Healthy: true,
|
||||||
|
Message: "Cluster is healthy",
|
||||||
|
Version: "v1.28.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toClusterResponse 将 Cluster 实体转换为响应 DTO(脱敏)
|
||||||
|
func (h *ClusterHandler) toClusterResponse(cluster *entity.Cluster) *dto.ClusterResponse {
|
||||||
|
return dto.ToClusterResponse(cluster)
|
||||||
|
}
|
||||||
371
backend/internal/adapter/input/http/rest/instance_handler.go
Normal file
371
backend/internal/adapter/input/http/rest/instance_handler.go
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceHandler 实例 Handler
|
||||||
|
type InstanceHandler struct {
|
||||||
|
instanceService *service.InstanceService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstanceHandler 创建实例 Handler
|
||||||
|
func NewInstanceHandler(instanceService *service.InstanceService) *InstanceHandler {
|
||||||
|
return &InstanceHandler{
|
||||||
|
instanceService: instanceService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInstance 创建实例
|
||||||
|
// @Summary 创建实例
|
||||||
|
// @Description 在指定集群上部署一个 artifact
|
||||||
|
// @Tags Instances
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param request body dto.CreateInstanceRequest true "实例配置"
|
||||||
|
// @Success 201 {object} dto.InstanceResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances [post]
|
||||||
|
func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
var req dto.CreateInstanceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Normalize()
|
||||||
|
|
||||||
|
// Extract chart name from repository (e.g., "charts/nginx" -> "nginx")
|
||||||
|
chart := req.Repository
|
||||||
|
if lastSlash := strings.LastIndex(req.Repository, "/"); lastSlash != -1 {
|
||||||
|
chart = req.Repository[lastSlash+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建实体
|
||||||
|
instance := entity.NewInstance(
|
||||||
|
clusterID,
|
||||||
|
req.Name,
|
||||||
|
req.Namespace,
|
||||||
|
req.RegistryID,
|
||||||
|
req.Repository,
|
||||||
|
chart, // Extracted chart name
|
||||||
|
req.Tag, // Tag mapped to version
|
||||||
|
)
|
||||||
|
instance.Description = req.Description
|
||||||
|
|
||||||
|
if req.Values != nil {
|
||||||
|
instance.SetValues(req.Values)
|
||||||
|
}
|
||||||
|
if req.ValuesYAML != "" {
|
||||||
|
instance.SetValuesYAML(req.ValuesYAML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.instanceService.CreateInstance(r.Context(), instance); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to create instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
response := &dto.InstanceResponse{
|
||||||
|
ID: instance.ID,
|
||||||
|
ClusterID: instance.ClusterID,
|
||||||
|
Name: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
RegistryID: instance.RegistryID,
|
||||||
|
Repository: instance.Repository,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
Version: instance.Version,
|
||||||
|
Description: instance.Description,
|
||||||
|
Status: string(instance.Status),
|
||||||
|
StatusReason: instance.StatusReason,
|
||||||
|
LastOperation: string(instance.LastOperation),
|
||||||
|
LastError: instance.LastError,
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Values: instance.Values,
|
||||||
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstance 获取实例详情
|
||||||
|
// @Summary 获取实例详情
|
||||||
|
// @Tags Instances
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param instance_id path string true "实例 ID"
|
||||||
|
// @Success 200 {object} dto.InstanceResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
|
||||||
|
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.InstanceResponse{
|
||||||
|
ID: instance.ID,
|
||||||
|
ClusterID: instance.ClusterID,
|
||||||
|
Name: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
RegistryID: instance.RegistryID,
|
||||||
|
Repository: instance.Repository,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
Version: instance.Version,
|
||||||
|
Description: instance.Description,
|
||||||
|
Status: string(instance.Status),
|
||||||
|
StatusReason: instance.StatusReason,
|
||||||
|
LastOperation: string(instance.LastOperation),
|
||||||
|
LastError: instance.LastError,
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Values: instance.Values,
|
||||||
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 列出集群的所有实例
|
||||||
|
// @Summary 列出实例
|
||||||
|
// @Tags Instances
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 200 {object} dto.InstanceListResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances [get]
|
||||||
|
func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
||||||
|
for _, instance := range instances {
|
||||||
|
responses = append(responses, &dto.InstanceResponse{
|
||||||
|
ID: instance.ID,
|
||||||
|
ClusterID: instance.ClusterID,
|
||||||
|
Name: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
RegistryID: instance.RegistryID,
|
||||||
|
Repository: instance.Repository,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
Version: instance.Version,
|
||||||
|
Description: instance.Description,
|
||||||
|
Status: string(instance.Status),
|
||||||
|
StatusReason: instance.StatusReason,
|
||||||
|
LastOperation: string(instance.LastOperation),
|
||||||
|
LastError: instance.LastError,
|
||||||
|
Revision: instance.Revision,
|
||||||
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.InstanceListResponse{
|
||||||
|
Instances: responses,
|
||||||
|
Total: len(responses),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstance 更新实例
|
||||||
|
// @Summary 更新实例
|
||||||
|
// @Tags Instances
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param instance_id path string true "实例 ID"
|
||||||
|
// @Param request body dto.UpdateInstanceRequest true "更新内容"
|
||||||
|
// @Success 200 {object} dto.InstanceResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances/{instance_id} [put]
|
||||||
|
func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
var req dto.UpdateInstanceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取现有实例
|
||||||
|
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if req.Version != "" {
|
||||||
|
instance.Upgrade(req.Version, req.Values)
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
instance.Description = req.Description
|
||||||
|
}
|
||||||
|
if req.ValuesYAML != "" {
|
||||||
|
instance.SetValuesYAML(req.ValuesYAML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.instanceService.UpdateInstance(r.Context(), instance); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to update instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &dto.InstanceResponse{
|
||||||
|
ID: instance.ID,
|
||||||
|
ClusterID: instance.ClusterID,
|
||||||
|
Name: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
RegistryID: instance.RegistryID,
|
||||||
|
Repository: instance.Repository,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
Version: instance.Version,
|
||||||
|
Description: instance.Description,
|
||||||
|
Status: string(instance.Status),
|
||||||
|
StatusReason: instance.StatusReason,
|
||||||
|
LastOperation: string(instance.LastOperation),
|
||||||
|
LastError: instance.LastError,
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Values: instance.Values,
|
||||||
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInstance 删除实例
|
||||||
|
// @Summary 删除实例
|
||||||
|
// @Tags Instances
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param instance_id path string true "实例 ID"
|
||||||
|
// @Success 204 {string} string "No Content"
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances/{instance_id} [delete]
|
||||||
|
func (h *InstanceHandler) DeleteInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
if err := h.instanceService.DeleteInstance(r.Context(), instanceID); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Failed to delete instance", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstanceEntries 获取实例入口
|
||||||
|
// @Summary 获取实例 Service/Ingress 入口
|
||||||
|
// @Tags Instances
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Param instance_id path string true "实例 ID"
|
||||||
|
// @Success 200 {array} dto.InstanceEntryResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /clusters/{cluster_id}/instances/{instance_id}/entries [get]
|
||||||
|
func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
entries, err := h.instanceService.ListInstanceEntries(r.Context(), clusterID, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch err {
|
||||||
|
case entity.ErrInstanceNotFound:
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case entity.ErrClusterNotFound:
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
respondError(w, status, "Failed to list instance entries", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]*dto.InstanceEntryResponse, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
responses = append(responses, convertInstanceEntry(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
|
||||||
|
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
|
||||||
|
for _, port := range entry.Ports {
|
||||||
|
portResponses = append(portResponses, dto.InstanceEntryPortResponse{
|
||||||
|
Name: port.Name,
|
||||||
|
Protocol: port.Protocol,
|
||||||
|
Port: port.Port,
|
||||||
|
TargetPort: port.TargetPort,
|
||||||
|
NodePort: port.NodePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hostResponses := make([]dto.InstanceEntryHostResponse, 0, len(entry.Hosts))
|
||||||
|
for _, host := range entry.Hosts {
|
||||||
|
pathResponses := make([]dto.InstanceEntryPathResponse, 0, len(host.Paths))
|
||||||
|
for _, path := range host.Paths {
|
||||||
|
pathResponses = append(pathResponses, dto.InstanceEntryPathResponse{
|
||||||
|
Path: path.Path,
|
||||||
|
ServiceName: path.ServiceName,
|
||||||
|
ServicePort: path.ServicePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hostResponses = append(hostResponses, dto.InstanceEntryHostResponse{
|
||||||
|
Host: host.Host,
|
||||||
|
Paths: pathResponses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsResponses := make([]dto.InstanceEntryTLSResponse, 0, len(entry.TLS))
|
||||||
|
for _, tls := range entry.TLS {
|
||||||
|
tlsResponses = append(tlsResponses, dto.InstanceEntryTLSResponse{
|
||||||
|
Hosts: tls.Hosts,
|
||||||
|
SecretName: tls.SecretName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.InstanceEntryResponse{
|
||||||
|
Kind: entry.Kind,
|
||||||
|
Name: entry.Name,
|
||||||
|
Namespace: entry.Namespace,
|
||||||
|
Type: entry.Type,
|
||||||
|
ClusterIP: entry.ClusterIP,
|
||||||
|
ExternalIPs: entry.ExternalIPs,
|
||||||
|
LoadBalancerIngress: entry.LoadBalancerIngress,
|
||||||
|
Ports: portResponses,
|
||||||
|
Hosts: hostResponses,
|
||||||
|
TLS: tlsResponses,
|
||||||
|
}
|
||||||
|
}
|
||||||
137
backend/internal/adapter/input/http/rest/monitoring_handler.go
Normal file
137
backend/internal/adapter/input/http/rest/monitoring_handler.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MonitoringHandler 监控处理器
|
||||||
|
type MonitoringHandler struct {
|
||||||
|
monitoringService *service.MonitoringService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitoringHandler 创建监控处理器
|
||||||
|
func NewMonitoringHandler(monitoringService *service.MonitoringService) *MonitoringHandler {
|
||||||
|
return &MonitoringHandler{
|
||||||
|
monitoringService: monitoringService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterMonitoring 获取单个集群的监控信息
|
||||||
|
// @Summary 获取集群监控
|
||||||
|
// @Tags Monitoring
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 200 {object} dto.ClusterMetricsResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /monitoring/clusters/{cluster_id} [get]
|
||||||
|
func (h *MonitoringHandler) GetClusterMonitoring(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
metrics, err := h.monitoringService.GetClusterMonitoring(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.ToClusterMetricsResponse(metrics)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClusterMonitoring 获取所有集群的监控信息
|
||||||
|
// @Summary 列出集群监控
|
||||||
|
// @Tags Monitoring
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} dto.ClusterMetricsResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /monitoring/clusters [get]
|
||||||
|
func (h *MonitoringHandler) ListClusterMonitoring(w http.ResponseWriter, r *http.Request) {
|
||||||
|
monitoringList, err := h.monitoringService.ListClusterMonitoring(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式
|
||||||
|
response := make([]*dto.ClusterMetricsResponse, len(monitoringList))
|
||||||
|
for i, m := range monitoringList {
|
||||||
|
response[i] = dto.ToClusterMetricsResponse(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMonitoringSummary 获取监控汇总信息
|
||||||
|
// @Summary 获取监控汇总
|
||||||
|
// @Tags Monitoring
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} dto.MonitoringSummaryResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /monitoring/summary [get]
|
||||||
|
func (h *MonitoringHandler) GetMonitoringSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
summary, err := h.monitoringService.GetMonitoringSummary(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.ToMonitoringSummaryResponse(summary)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeMetrics 获取集群的节点指标
|
||||||
|
// @Summary 获取节点指标
|
||||||
|
// @Tags Monitoring
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param cluster_id path string true "集群 ID"
|
||||||
|
// @Success 200 {array} dto.NodeMetricsResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /monitoring/clusters/{cluster_id}/nodes [get]
|
||||||
|
func (h *MonitoringHandler) GetNodeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
|
||||||
|
nodes, err := h.monitoringService.GetNodeMetrics(r.Context(), clusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式
|
||||||
|
response := make([]dto.NodeMetricsResponse, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
response[i] = dto.NodeMetricsResponse{
|
||||||
|
NodeName: node.NodeName,
|
||||||
|
Status: node.Status,
|
||||||
|
Role: node.Role,
|
||||||
|
Age: node.Age,
|
||||||
|
PodCount: node.PodCount,
|
||||||
|
CPUCapacity: node.CPUCapacity,
|
||||||
|
CPUAllocatable: node.CPUAllocatable,
|
||||||
|
CPUUsage: node.CPUUsage,
|
||||||
|
CPUPercent: node.CPUPercent,
|
||||||
|
MemoryCapacity: node.MemoryCapacity,
|
||||||
|
MemoryAllocatable: node.MemoryAllocatable,
|
||||||
|
MemoryUsage: node.MemoryUsage,
|
||||||
|
MemoryPercent: node.MemoryPercent,
|
||||||
|
GPUCapacity: node.GPUCapacity,
|
||||||
|
GPUUsage: node.GPUUsage,
|
||||||
|
GPUPercent: node.GPUPercent,
|
||||||
|
GPUType: node.GPUType,
|
||||||
|
OSImage: node.OSImage,
|
||||||
|
KernelVersion: node.KernelVersion,
|
||||||
|
ContainerRuntime: node.ContainerRuntime,
|
||||||
|
KubeletVersion: node.KubeletVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
201
backend/internal/adapter/input/http/rest/registry_handler.go
Normal file
201
backend/internal/adapter/input/http/rest/registry_handler.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryHandler Registry Handler
|
||||||
|
type RegistryHandler struct {
|
||||||
|
registryService *service.RegistryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistryHandler 创建 Registry Handler
|
||||||
|
func NewRegistryHandler(registryService *service.RegistryService) *RegistryHandler {
|
||||||
|
return &RegistryHandler{
|
||||||
|
registryService: registryService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistry 创建 Registry
|
||||||
|
// @Summary 创建 Registry
|
||||||
|
// @Description 新增 OCI Registry 配置
|
||||||
|
// @Tags Registries
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param request body dto.CreateRegistryRequest true "Registry 信息"
|
||||||
|
// @Success 201 {object} dto.RegistryResponse
|
||||||
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries [post]
|
||||||
|
func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.CreateRegistryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建实体
|
||||||
|
registry := entity.NewRegistry(req.Name, req.URL)
|
||||||
|
registry.Description = req.Description
|
||||||
|
registry.Insecure = req.Insecure
|
||||||
|
registry.SetCredentials(req.Username, req.Password)
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.registryService.CreateRegistry(r.Context(), registry); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to create registry", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应(脱敏)
|
||||||
|
response := dto.ToRegistryResponse(registry)
|
||||||
|
respondJSON(w, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistry 获取 Registry 详情
|
||||||
|
// @Summary 获取 Registry
|
||||||
|
// @Tags Registries
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Success 200 {object} dto.RegistryResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id} [get]
|
||||||
|
func (h *RegistryHandler) GetRegistry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
|
||||||
|
registry, err := h.registryService.GetRegistry(r.Context(), registryID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Registry not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应(脱敏)
|
||||||
|
response := dto.ToRegistryResponse(registry)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllRegistries 获取所有 Registries
|
||||||
|
// @Summary 列出所有 Registries
|
||||||
|
// @Tags Registries
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} dto.RegistryResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries [get]
|
||||||
|
func (h *RegistryHandler) GetAllRegistries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
registries, err := h.registryService.ListRegistries(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to list registries", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应(脱敏)
|
||||||
|
responses := make([]*dto.RegistryResponse, 0, len(registries))
|
||||||
|
for _, registry := range registries {
|
||||||
|
responses = append(responses, dto.ToRegistryResponse(registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRegistry 更新 Registry
|
||||||
|
// @Summary 更新 Registry
|
||||||
|
// @Tags Registries
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Param request body dto.UpdateRegistryRequest true "更新内容"
|
||||||
|
// @Success 200 {object} dto.RegistryResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id} [put]
|
||||||
|
func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
|
||||||
|
var req dto.UpdateRegistryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取现有 Registry
|
||||||
|
registry, err := h.registryService.GetRegistry(r.Context(), registryID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Registry not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
registry.Update(req.Name, req.URL, req.Description)
|
||||||
|
registry.Insecure = req.Insecure
|
||||||
|
if req.Username != "" || req.Password != "" {
|
||||||
|
registry.SetCredentials(req.Username, req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用领域服务
|
||||||
|
if err := h.registryService.UpdateRegistry(r.Context(), registry); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Failed to update registry", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应(脱敏)
|
||||||
|
response := dto.ToRegistryResponse(registry)
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRegistry 删除 Registry
|
||||||
|
// @Summary 删除 Registry
|
||||||
|
// @Tags Registries
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Success 204 {string} string "No Content"
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id} [delete]
|
||||||
|
func (h *RegistryHandler) DeleteRegistry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
|
||||||
|
if err := h.registryService.DeleteRegistry(r.Context(), registryID); err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "Failed to delete registry", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistryHealth 获取 Registry 健康状态
|
||||||
|
// @Summary 检查 Registry 健康
|
||||||
|
// @Tags Registries
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Success 200 {object} dto.RegistryHealthResponse
|
||||||
|
// @Router /registries/{registry_id}/health [get]
|
||||||
|
func (h *RegistryHandler) GetRegistryHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
|
||||||
|
// 调用领域服务检查健康状态
|
||||||
|
err := h.registryService.CheckHealth(r.Context(), registryID)
|
||||||
|
|
||||||
|
response := &dto.RegistryHealthResponse{
|
||||||
|
Healthy: err == nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.Message = err.Error()
|
||||||
|
} else {
|
||||||
|
response.Message = "Registry is healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
89
backend/internal/adapter/input/http/rest/swagger-ui.html
Normal file
89
backend/internal/adapter/input/http/rest/swagger-ui.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OCDP Backend API - Swagger UI</title>
|
||||||
|
<link rel="stylesheet" href="/api/docs/assets/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: -moz-scrollbars-vertical;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
#swagger-ui {
|
||||||
|
max-width: 1460px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info {
|
||||||
|
margin: 50px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .scheme-container {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.15);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="/api/docs/assets/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="/api/docs/assets/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
// Build a system
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: "/api/docs/openapi.yaml",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
defaultModelsExpandDepth: 1,
|
||||||
|
defaultModelExpandDepth: 1,
|
||||||
|
docExpansion: "list",
|
||||||
|
filter: true,
|
||||||
|
showRequestHeaders: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
|
||||||
|
validatorUrl: null,
|
||||||
|
onComplete: function() {
|
||||||
|
console.log("Swagger UI loaded successfully");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.ui = ui;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
backend/internal/adapter/input/http/rest/swagger_handler.go
Normal file
68
backend/internal/adapter/input/http/rest/swagger_handler.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
repoDocs "github.com/ocdp/cluster-service/docs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed swagger-ui.html
|
||||||
|
swaggerHTML []byte
|
||||||
|
|
||||||
|
//go:embed swaggerui/swagger-ui.css
|
||||||
|
swaggerCSS []byte
|
||||||
|
|
||||||
|
//go:embed swaggerui/swagger-ui-bundle.js
|
||||||
|
swaggerBundleJS []byte
|
||||||
|
|
||||||
|
//go:embed swaggerui/swagger-ui-standalone-preset.js
|
||||||
|
swaggerStandalonePresetJS []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
// SwaggerHandler Swagger UI Handler
|
||||||
|
type SwaggerHandler struct{}
|
||||||
|
|
||||||
|
// NewSwaggerHandler 创建 Swagger Handler
|
||||||
|
func NewSwaggerHandler() *SwaggerHandler {
|
||||||
|
return &SwaggerHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSwaggerUI 提供 Swagger UI 页面
|
||||||
|
func (h *SwaggerHandler) ServeSwaggerUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(swaggerHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSwaggerCSS 提供 Swagger UI 样式
|
||||||
|
func (h *SwaggerHandler) ServeSwaggerCSS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(swaggerCSS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSwaggerBundle 提供 Swagger UI 主脚本
|
||||||
|
func (h *SwaggerHandler) ServeSwaggerBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(swaggerBundleJS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSwaggerStandalonePreset 提供 Swagger UI 预设脚本
|
||||||
|
func (h *SwaggerHandler) ServeSwaggerStandalonePreset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(swaggerStandalonePresetJS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeOpenAPISpec 提供 OpenAPI 规范文件
|
||||||
|
func (h *SwaggerHandler) ServeOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(repoDocs.OpenAPISpec)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
35
backend/internal/adapter/input/http/rest/utils.go
Normal file
35
backend/internal/adapter/input/http/rest/utils.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// respondJSON 返回 JSON 响应
|
||||||
|
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError 返回错误响应
|
||||||
|
func respondError(w http.ResponseWriter, statusCode int, error string, message string) {
|
||||||
|
response := &dto.ErrorResponse{
|
||||||
|
Error: error,
|
||||||
|
Message: message,
|
||||||
|
Code: statusCode,
|
||||||
|
}
|
||||||
|
respondJSON(w, statusCode, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondSuccess 返回成功响应
|
||||||
|
func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
|
||||||
|
response := &dto.SuccessResponse{
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
220
backend/internal/adapter/output/factory.go
Normal file
220
backend/internal/adapter/output/factory.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
helmMock "github.com/ocdp/cluster-service/internal/adapter/output/helm/mock"
|
||||||
|
helmReal "github.com/ocdp/cluster-service/internal/adapter/output/helm/real"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output/k8s"
|
||||||
|
ociMock "github.com/ocdp/cluster-service/internal/adapter/output/oci/mock"
|
||||||
|
ociReal "github.com/ocdp/cluster-service/internal/adapter/output/oci/real"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output/persistence/postgres"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdapterMode 适配器模式
|
||||||
|
type AdapterMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeMock AdapterMode = "mock" // Mock 模式(内存存储,用于开发调试)
|
||||||
|
// 默认模式:连接真实 PostgreSQL 和服务(任何非 "mock" 的值都是默认模式)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdapterFactory 适配器工厂
|
||||||
|
// 用于创建所有 Output Adapters,支持 Mock 和真实实现切换
|
||||||
|
type AdapterFactory struct {
|
||||||
|
mode AdapterMode
|
||||||
|
encryptor crypto.Encryptor // 加密器(用于敏感数据加密)
|
||||||
|
|
||||||
|
// 数据库连接字符串(非 Mock 模式需要)
|
||||||
|
dbConnString string
|
||||||
|
|
||||||
|
// 数据库连接(非 Mock 模式)
|
||||||
|
db *postgres.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdapterFactory 创建适配器工厂
|
||||||
|
func NewAdapterFactory(mode AdapterMode, encryptor crypto.Encryptor, dbConnString string) *AdapterFactory {
|
||||||
|
return &AdapterFactory{
|
||||||
|
mode: mode,
|
||||||
|
encryptor: encryptor,
|
||||||
|
dbConnString: dbConnString,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserRepository 创建用户仓储
|
||||||
|
func (f *AdapterFactory) CreateUserRepository() (repository.UserRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewUserRepositoryMock(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(PostgreSQL)
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewUserRepository(f.db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClusterRepository 创建集群仓储
|
||||||
|
func (f *AdapterFactory) CreateClusterRepository() (repository.ClusterRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewClusterRepositoryMock(f.encryptor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(PostgreSQL)
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewClusterRepository(f.db, f.encryptor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistryRepository 创建 Registry 仓储
|
||||||
|
func (f *AdapterFactory) CreateRegistryRepository() (repository.RegistryRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewRegistryRepositoryMock(f.encryptor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(PostgreSQL)
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewRegistryRepository(f.db, f.encryptor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInstanceRepository 创建实例仓储
|
||||||
|
func (f *AdapterFactory) CreateInstanceRepository() (repository.InstanceRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewInstanceRepositoryMock(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(PostgreSQL)
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewInstanceRepository(f.db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOCIClient 创建 OCI 客户端
|
||||||
|
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return ociMock.NewOCIClientMock(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(ORAS SDK)
|
||||||
|
return ociReal.NewOCIClient(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateHelmClient 创建 Helm 客户端
|
||||||
|
func (f *AdapterFactory) CreateHelmClient() (repository.HelmClient, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return helmMock.NewHelmClientMock(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:真实实现(Helm SDK)
|
||||||
|
return helmReal.NewHelmClient(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMetricsClient 创建 Metrics 客户端
|
||||||
|
func (f *AdapterFactory) CreateMetricsClient(clusterRepo repository.ClusterRepository) repository.MetricsClient {
|
||||||
|
// Metrics client 总是使用真实的 Kubernetes API
|
||||||
|
return k8s.NewMetricsClient(clusterRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntryClient 创建实例入口查询客户端
|
||||||
|
func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
|
||||||
|
return k8s.NewEntryClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAllRepositories 一次性创建所有 Repositories
|
||||||
|
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
||||||
|
userRepo, err := f.CreateUserRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterRepo, err := f.CreateClusterRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create cluster repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registryRepo, err := f.CreateRegistryRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create registry repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceRepo, err := f.CreateInstanceRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create instance repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ociClient, err := f.CreateOCIClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create OCI client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
helmClient, err := f.CreateHelmClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create Helm client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Metrics client(依赖 clusterRepo)
|
||||||
|
metricsClient := f.CreateMetricsClient(clusterRepo)
|
||||||
|
entryClient := f.CreateEntryClient()
|
||||||
|
|
||||||
|
return &Repositories{
|
||||||
|
UserRepo: userRepo,
|
||||||
|
ClusterRepo: clusterRepo,
|
||||||
|
RegistryRepo: registryRepo,
|
||||||
|
InstanceRepo: instanceRepo,
|
||||||
|
OCIClient: ociClient,
|
||||||
|
HelmClient: helmClient,
|
||||||
|
MetricsClient: metricsClient,
|
||||||
|
EntryClient: entryClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repositories 所有仓储的集合
|
||||||
|
type Repositories struct {
|
||||||
|
UserRepo repository.UserRepository
|
||||||
|
ClusterRepo repository.ClusterRepository
|
||||||
|
RegistryRepo repository.RegistryRepository
|
||||||
|
InstanceRepo repository.InstanceRepository
|
||||||
|
OCIClient repository.OCIClient
|
||||||
|
HelmClient repository.HelmClient
|
||||||
|
MetricsClient repository.MetricsClient
|
||||||
|
EntryClient repository.InstanceEntryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDBConnection 确保数据库连接已建立
|
||||||
|
func (f *AdapterFactory) ensureDBConnection() error {
|
||||||
|
if f.db != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.dbConnString == "" {
|
||||||
|
return fmt.Errorf("database connection string is required (set DATABASE_URL environment variable)")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := postgres.NewDB(f.dbConnString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库 schema
|
||||||
|
if err := db.InitSchema(); err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize database schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.db = db
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭工厂资源
|
||||||
|
func (f *AdapterFactory) Close() error {
|
||||||
|
if f.db != nil {
|
||||||
|
return f.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
196
backend/internal/adapter/output/helm/mock/helm_client_mock.go
Normal file
196
backend/internal/adapter/output/helm/mock/helm_client_mock.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelmClientMock Helm 客户端 Mock 实现
|
||||||
|
type HelmClientMock struct {
|
||||||
|
// Mock 数据存储
|
||||||
|
releases map[string]map[string]*entity.Instance // clusterID -> releaseName -> instance
|
||||||
|
history map[string]map[string][]*entity.ReleaseHistory // clusterID -> releaseName -> []history
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHelmClientMock 创建 Mock 实现
|
||||||
|
func NewHelmClientMock() repository.HelmClient {
|
||||||
|
return &HelmClientMock{
|
||||||
|
releases: make(map[string]map[string]*entity.Instance),
|
||||||
|
history: make(map[string]map[string][]*entity.ReleaseHistory),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
// 初始化集群数据
|
||||||
|
if c.releases[cluster.ID] == nil {
|
||||||
|
c.releases[cluster.ID] = make(map[string]*entity.Instance)
|
||||||
|
c.history[cluster.ID] = make(map[string][]*entity.ReleaseHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
key := fmt.Sprintf("%s/%s", instance.Namespace, instance.Name)
|
||||||
|
if _, exists := c.releases[cluster.ID][key]; exists {
|
||||||
|
return entity.ErrInstanceExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 安装
|
||||||
|
instance.Status = entity.StatusDeployed
|
||||||
|
instance.Revision = 1
|
||||||
|
instance.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
c.releases[cluster.ID][key] = instance
|
||||||
|
|
||||||
|
// 添加历史记录
|
||||||
|
c.history[cluster.ID][key] = []*entity.ReleaseHistory{
|
||||||
|
{
|
||||||
|
Revision: 1,
|
||||||
|
Updated: time.Now(),
|
||||||
|
Status: entity.StatusDeployed,
|
||||||
|
Chart: fmt.Sprintf("%s-%s", instance.Chart, instance.Version),
|
||||||
|
AppVersion: instance.Version,
|
||||||
|
Description: "Install complete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
key := fmt.Sprintf("%s/%s", instance.Namespace, instance.Name)
|
||||||
|
|
||||||
|
existing, exists := c.releases[cluster.ID][key]
|
||||||
|
if !exists {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 升级
|
||||||
|
instance.Revision = existing.Revision + 1
|
||||||
|
instance.Status = entity.StatusDeployed
|
||||||
|
instance.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
c.releases[cluster.ID][key] = instance
|
||||||
|
|
||||||
|
// 添加历史记录
|
||||||
|
history := &entity.ReleaseHistory{
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Updated: time.Now(),
|
||||||
|
Status: entity.StatusDeployed,
|
||||||
|
Chart: fmt.Sprintf("%s-%s", instance.Chart, instance.Version),
|
||||||
|
AppVersion: instance.Version,
|
||||||
|
Description: "Upgrade complete",
|
||||||
|
}
|
||||||
|
c.history[cluster.ID][key] = append(c.history[cluster.ID][key], history)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||||
|
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||||
|
|
||||||
|
if _, exists := c.releases[cluster.ID][key]; !exists {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 卸载
|
||||||
|
delete(c.releases[cluster.ID], key)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||||
|
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||||
|
|
||||||
|
instance, exists := c.releases[cluster.ID][key]
|
||||||
|
if !exists {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查历史记录是否存在
|
||||||
|
histories := c.history[cluster.ID][key]
|
||||||
|
if revision > len(histories) || revision < 1 {
|
||||||
|
return fmt.Errorf("revision %d not found", revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 回滚
|
||||||
|
instance.Revision = len(histories) + 1
|
||||||
|
instance.Status = entity.StatusDeployed
|
||||||
|
instance.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
c.releases[cluster.ID][key] = instance
|
||||||
|
|
||||||
|
// 添加回滚历史记录
|
||||||
|
history := &entity.ReleaseHistory{
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Updated: time.Now(),
|
||||||
|
Status: entity.StatusDeployed,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
AppVersion: instance.Version,
|
||||||
|
Description: fmt.Sprintf("Rollback to revision %d", revision),
|
||||||
|
}
|
||||||
|
c.history[cluster.ID][key] = append(c.history[cluster.ID][key], history)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||||
|
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||||
|
|
||||||
|
instance, exists := c.releases[cluster.ID][key]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||||
|
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||||
|
|
||||||
|
if _, exists := c.releases[cluster.ID][key]; !exists {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
histories := c.history[cluster.ID][key]
|
||||||
|
if histories == nil {
|
||||||
|
return []*entity.ReleaseHistory{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return histories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||||
|
clusterReleases := c.releases[cluster.ID]
|
||||||
|
if clusterReleases == nil {
|
||||||
|
return []*entity.Instance{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0)
|
||||||
|
for key, instance := range clusterReleases {
|
||||||
|
// 如果指定了 namespace,只返回该 namespace 的
|
||||||
|
if namespace != "" && namespace != "all" {
|
||||||
|
keyNamespace := instance.Namespace
|
||||||
|
if keyNamespace != namespace {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instances = append(instances, c.releases[cluster.ID][key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||||
|
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||||
|
|
||||||
|
instance, exists := c.releases[cluster.ID][key]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance.Values, nil
|
||||||
|
}
|
||||||
|
|
||||||
313
backend/internal/adapter/output/helm/real/helm_client.go
Normal file
313
backend/internal/adapter/output/helm/real/helm_client.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package real
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"helm.sh/helm/v3/pkg/action"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
"helm.sh/helm/v3/pkg/cli"
|
||||||
|
"helm.sh/helm/v3/pkg/release"
|
||||||
|
"helm.sh/helm/v3/pkg/storage/driver"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
"k8s.io/client-go/discovery"
|
||||||
|
"k8s.io/client-go/discovery/cached/memory"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/restmapper"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelmClient 真实的 Helm 客户端实现
|
||||||
|
type HelmClient struct {
|
||||||
|
settings *cli.EnvSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHelmClient 创建真实的 Helm 客户端
|
||||||
|
func NewHelmClient() repository.HelmClient {
|
||||||
|
return &HelmClient{
|
||||||
|
settings: cli.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActionConfig 获取 Helm action configuration
|
||||||
|
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, error) {
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
|
||||||
|
// 创建临时 kubeconfig 文件
|
||||||
|
kubeconfigContent := cluster.GetKubeConfig()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
|
||||||
|
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 kubeconfig 初始化 action config
|
||||||
|
if err := actionConfig.Init(
|
||||||
|
&kubeconfigGetter{kubeconfigPath: kubeconfigPath},
|
||||||
|
namespace,
|
||||||
|
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
|
||||||
|
func(format string, v ...interface{}) {
|
||||||
|
// Log function
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize action config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actionConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubeconfigGetter implements RESTClientGetter
|
||||||
|
type kubeconfigGetter struct {
|
||||||
|
kubeconfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
|
||||||
|
return clientcmd.BuildConfigFromFlags("", k.kubeconfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kubeconfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
|
||||||
|
config, err := k.ToRESTConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
discoveryClient := discovery.NewDiscoveryClientForConfigOrDie(config)
|
||||||
|
// Wrap in a memory cache
|
||||||
|
return memory.NewMemCacheClient(discoveryClient), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kubeconfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
||||||
|
discoveryClient, err := k.ToDiscoveryClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient)
|
||||||
|
return mapper, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
||||||
|
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||||
|
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
|
||||||
|
&clientcmd.ConfigOverrides{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install 安装 Helm Chart
|
||||||
|
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
install := action.NewInstall(actionConfig)
|
||||||
|
install.ReleaseName = instance.Name
|
||||||
|
install.Namespace = instance.Namespace
|
||||||
|
install.CreateNamespace = true
|
||||||
|
install.Wait = true
|
||||||
|
install.Timeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// 加载 Chart(从本地路径或 OCI registry)
|
||||||
|
// 这里简化处理,假设 chart 已经被拉取到本地
|
||||||
|
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||||
|
|
||||||
|
chart, err := loader.Load(chartPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load chart: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行安装
|
||||||
|
rel, err := install.Run(chart, instance.Values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to install release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 revision(状态由调用方根据操作结果设置)
|
||||||
|
instance.Revision = rel.Version
|
||||||
|
// 注意:不在这里设置 Status,让调用方通过 MarkSuccess/MarkFailure 来设置
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade 升级 Helm Release
|
||||||
|
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
upgrade := action.NewUpgrade(actionConfig)
|
||||||
|
upgrade.Namespace = instance.Namespace
|
||||||
|
upgrade.Wait = true
|
||||||
|
upgrade.Timeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// 加载 Chart
|
||||||
|
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||||
|
|
||||||
|
chart, err := loader.Load(chartPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load chart: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行升级
|
||||||
|
rel, err := upgrade.Run(instance.Name, chart, instance.Values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to upgrade release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 revision(状态由调用方根据操作结果设置)
|
||||||
|
instance.Revision = rel.Version
|
||||||
|
// 注意:不在这里设置 Status,让调用方通过 MarkSuccess/MarkFailure 来设置
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall 卸载 Helm Release
|
||||||
|
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall := action.NewUninstall(actionConfig)
|
||||||
|
uninstall.Wait = true
|
||||||
|
uninstall.Timeout = 5 * time.Minute
|
||||||
|
|
||||||
|
_, err = uninstall.Run(releaseName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, driver.ErrReleaseNotFound) {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to uninstall release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback 回滚 Helm Release
|
||||||
|
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback := action.NewRollback(actionConfig)
|
||||||
|
rollback.Version = revision
|
||||||
|
rollback.Wait = true
|
||||||
|
rollback.Timeout = 5 * time.Minute
|
||||||
|
|
||||||
|
if err := rollback.Run(releaseName); err != nil {
|
||||||
|
return fmt.Errorf("failed to rollback release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus 获取 Release 状态
|
||||||
|
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := action.NewStatus(actionConfig)
|
||||||
|
rel, err := status.Run(releaseName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get release status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.convertReleaseToInstance(rel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory 获取 Release 历史
|
||||||
|
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
history := action.NewHistory(actionConfig)
|
||||||
|
history.Max = 256
|
||||||
|
|
||||||
|
releases, err := history.Run(releaseName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get release history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*entity.ReleaseHistory, 0, len(releases))
|
||||||
|
for _, rel := range releases {
|
||||||
|
result = append(result, &entity.ReleaseHistory{
|
||||||
|
Revision: rel.Version,
|
||||||
|
Updated: rel.Info.LastDeployed.Time,
|
||||||
|
Status: entity.InstanceStatus(rel.Info.Status),
|
||||||
|
Chart: rel.Chart.Metadata.Name,
|
||||||
|
AppVersion: rel.Chart.Metadata.AppVersion,
|
||||||
|
Description: rel.Info.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出集群中的所有 Releases
|
||||||
|
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := action.NewList(actionConfig)
|
||||||
|
if namespace == "" {
|
||||||
|
list.AllNamespaces = true
|
||||||
|
}
|
||||||
|
|
||||||
|
releases, err := list.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list releases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0, len(releases))
|
||||||
|
for _, rel := range releases {
|
||||||
|
instances = append(instances, h.convertReleaseToInstance(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValues 获取 Release 的 values
|
||||||
|
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||||
|
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
getValues := action.NewGetValues(actionConfig)
|
||||||
|
values, err := getValues.Run(releaseName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get values: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertReleaseToInstance 转换 Helm Release 为 Instance
|
||||||
|
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
|
||||||
|
return &entity.Instance{
|
||||||
|
Name: rel.Name,
|
||||||
|
Namespace: rel.Namespace,
|
||||||
|
Chart: rel.Chart.Metadata.Name,
|
||||||
|
Version: rel.Chart.Metadata.Version,
|
||||||
|
Status: entity.InstanceStatus(rel.Info.Status),
|
||||||
|
Revision: rel.Version,
|
||||||
|
Values: rel.Config,
|
||||||
|
UpdatedAt: rel.Info.LastDeployed.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
321
backend/internal/adapter/output/k8s/entry_client.go
Normal file
321
backend/internal/adapter/output/k8s/entry_client.go
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
networkingv1 "k8s.io/api/networking/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntryClient 使用 Kubernetes API 查询实例相关 Service/Ingress
|
||||||
|
type EntryClient struct{}
|
||||||
|
|
||||||
|
// NewEntryClient 创建 EntryClient
|
||||||
|
func NewEntryClient() repository.InstanceEntryClient {
|
||||||
|
return &EntryClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEntries 查询实例的 Service/Ingress 入口
|
||||||
|
func (c *EntryClient) ListEntries(
|
||||||
|
ctx context.Context,
|
||||||
|
cluster *entity.Cluster,
|
||||||
|
instance *entity.Instance,
|
||||||
|
) ([]*entity.InstanceEntry, error) {
|
||||||
|
clientset, err := c.createClientset(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||||
|
|
||||||
|
serviceEntries, err := c.collectServiceEntries(ctx, clientset, instance, selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ingressEntries, err := c.collectIngressEntries(ctx, clientset, instance, selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(serviceEntries, ingressEntries...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EntryClient) collectServiceEntries(
|
||||||
|
ctx context.Context,
|
||||||
|
clientset *kubernetes.Clientset,
|
||||||
|
instance *entity.Instance,
|
||||||
|
selector string,
|
||||||
|
) ([]*entity.InstanceEntry, error) {
|
||||||
|
services, err := c.listServices(ctx, clientset, instance.Namespace, selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := convertServicesToEntries(services, instance, selector == "")
|
||||||
|
if len(entries) == 0 && selector != "" {
|
||||||
|
// Fallback: widen the search scope and filter manually.
|
||||||
|
services, err = c.listServices(ctx, clientset, instance.Namespace, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = convertServicesToEntries(services, instance, true)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EntryClient) collectIngressEntries(
|
||||||
|
ctx context.Context,
|
||||||
|
clientset *kubernetes.Clientset,
|
||||||
|
instance *entity.Instance,
|
||||||
|
selector string,
|
||||||
|
) ([]*entity.InstanceEntry, error) {
|
||||||
|
ingresses, err := c.listIngresses(ctx, clientset, instance.Namespace, selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := convertIngressesToEntries(ingresses, instance, selector == "")
|
||||||
|
if len(entries) == 0 && selector != "" {
|
||||||
|
ingresses, err = c.listIngresses(ctx, clientset, instance.Namespace, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = convertIngressesToEntries(ingresses, instance, true)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EntryClient) listServices(
|
||||||
|
ctx context.Context,
|
||||||
|
clientset *kubernetes.Clientset,
|
||||||
|
namespace, selector string,
|
||||||
|
) ([]corev1.Service, error) {
|
||||||
|
listOptions := metav1.ListOptions{}
|
||||||
|
if selector != "" {
|
||||||
|
listOptions.LabelSelector = selector
|
||||||
|
}
|
||||||
|
services, err := clientset.CoreV1().
|
||||||
|
Services(namespace).
|
||||||
|
List(ctx, listOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list services: %w", err)
|
||||||
|
}
|
||||||
|
return services.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EntryClient) listIngresses(
|
||||||
|
ctx context.Context,
|
||||||
|
clientset *kubernetes.Clientset,
|
||||||
|
namespace, selector string,
|
||||||
|
) ([]networkingv1.Ingress, error) {
|
||||||
|
listOptions := metav1.ListOptions{}
|
||||||
|
if selector != "" {
|
||||||
|
listOptions.LabelSelector = selector
|
||||||
|
}
|
||||||
|
ingresses, err := clientset.NetworkingV1().
|
||||||
|
Ingresses(namespace).
|
||||||
|
List(ctx, listOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list ingresses: %w", err)
|
||||||
|
}
|
||||||
|
return ingresses.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertServicesToEntries(services []corev1.Service, instance *entity.Instance, enforceMatch bool) []*entity.InstanceEntry {
|
||||||
|
entries := make([]*entity.InstanceEntry, 0, len(services))
|
||||||
|
for _, svc := range services {
|
||||||
|
if enforceMatch && !resourceMatchesInstance(svc.ObjectMeta, instance) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, convertServiceToEntry(&svc))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIngressesToEntries(ingresses []networkingv1.Ingress, instance *entity.Instance, enforceMatch bool) []*entity.InstanceEntry {
|
||||||
|
entries := make([]*entity.InstanceEntry, 0, len(ingresses))
|
||||||
|
for _, ing := range ingresses {
|
||||||
|
if enforceMatch && !resourceMatchesInstance(ing.ObjectMeta, instance) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, convertIngressToEntry(&ing))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EntryClient) createClientset(cluster *entity.Cluster) (*kubernetes.Clientset, error) {
|
||||||
|
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.GetKubeConfig()))
|
||||||
|
if err != nil {
|
||||||
|
config = &rest.Config{
|
||||||
|
Host: cluster.Host,
|
||||||
|
TLSClientConfig: rest.TLSClientConfig{
|
||||||
|
CAData: []byte(cluster.CAData),
|
||||||
|
CertData: []byte(cluster.CertData),
|
||||||
|
KeyData: []byte(cluster.KeyData),
|
||||||
|
},
|
||||||
|
BearerToken: cluster.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertServiceToEntry(svc *corev1.Service) *entity.InstanceEntry {
|
||||||
|
clusterIP := svc.Spec.ClusterIP
|
||||||
|
if clusterIP == corev1.ClusterIPNone {
|
||||||
|
clusterIP = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lbIngress := make([]string, 0, len(svc.Status.LoadBalancer.Ingress))
|
||||||
|
for _, ing := range svc.Status.LoadBalancer.Ingress {
|
||||||
|
if ing.IP != "" {
|
||||||
|
lbIngress = append(lbIngress, ing.IP)
|
||||||
|
}
|
||||||
|
if ing.Hostname != "" {
|
||||||
|
lbIngress = append(lbIngress, ing.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := make([]entity.InstanceEntryPort, 0, len(svc.Spec.Ports))
|
||||||
|
for _, port := range svc.Spec.Ports {
|
||||||
|
ports = append(ports, entity.InstanceEntryPort{
|
||||||
|
Name: port.Name,
|
||||||
|
Protocol: string(port.Protocol),
|
||||||
|
Port: port.Port,
|
||||||
|
TargetPort: intOrStringToString(port.TargetPort),
|
||||||
|
NodePort: port.NodePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.InstanceEntry{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: svc.Name,
|
||||||
|
Namespace: svc.Namespace,
|
||||||
|
Type: string(svc.Spec.Type),
|
||||||
|
ClusterIP: clusterIP,
|
||||||
|
ExternalIPs: append([]string{}, svc.Spec.ExternalIPs...),
|
||||||
|
LoadBalancerIngress: lbIngress,
|
||||||
|
Ports: ports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIngressToEntry(ing *networkingv1.Ingress) *entity.InstanceEntry {
|
||||||
|
lbIngress := make([]string, 0, len(ing.Status.LoadBalancer.Ingress))
|
||||||
|
for _, addr := range ing.Status.LoadBalancer.Ingress {
|
||||||
|
if addr.IP != "" {
|
||||||
|
lbIngress = append(lbIngress, addr.IP)
|
||||||
|
}
|
||||||
|
if addr.Hostname != "" {
|
||||||
|
lbIngress = append(lbIngress, addr.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := make([]entity.InstanceEntryHost, 0, len(ing.Spec.Rules))
|
||||||
|
for _, rule := range ing.Spec.Rules {
|
||||||
|
hostEntry := entity.InstanceEntryHost{
|
||||||
|
Host: rule.Host,
|
||||||
|
}
|
||||||
|
if rule.HTTP != nil {
|
||||||
|
paths := make([]entity.InstanceEntryPath, 0, len(rule.HTTP.Paths))
|
||||||
|
for _, path := range rule.HTTP.Paths {
|
||||||
|
name := ""
|
||||||
|
port := ""
|
||||||
|
if path.Backend.Service != nil {
|
||||||
|
name = path.Backend.Service.Name
|
||||||
|
port = serviceBackendPortString(path.Backend.Service.Port)
|
||||||
|
}
|
||||||
|
paths = append(paths, entity.InstanceEntryPath{
|
||||||
|
Path: path.Path,
|
||||||
|
ServiceName: name,
|
||||||
|
ServicePort: port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hostEntry.Paths = paths
|
||||||
|
}
|
||||||
|
hosts = append(hosts, hostEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsEntries := make([]entity.InstanceEntryTLS, 0, len(ing.Spec.TLS))
|
||||||
|
for _, tls := range ing.Spec.TLS {
|
||||||
|
tlsEntries = append(tlsEntries, entity.InstanceEntryTLS{
|
||||||
|
Hosts: append([]string{}, tls.Hosts...),
|
||||||
|
SecretName: tls.SecretName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entryType := "Ingress"
|
||||||
|
if ing.Spec.IngressClassName != nil {
|
||||||
|
entryType = *ing.Spec.IngressClassName
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entity.InstanceEntry{
|
||||||
|
Kind: "Ingress",
|
||||||
|
Name: ing.Name,
|
||||||
|
Namespace: ing.Namespace,
|
||||||
|
Type: entryType,
|
||||||
|
LoadBalancerIngress: lbIngress,
|
||||||
|
Hosts: hosts,
|
||||||
|
TLS: tlsEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intOrStringToString(v intstr.IntOrString) string {
|
||||||
|
if v.Type == intstr.String {
|
||||||
|
return v.StrVal
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", v.IntValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBackendPortString(port networkingv1.ServiceBackendPort) string {
|
||||||
|
if port.Name != "" {
|
||||||
|
return port.Name
|
||||||
|
}
|
||||||
|
if port.Number != 0 {
|
||||||
|
return fmt.Sprintf("%d", port.Number)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceMatchesInstance(meta metav1.ObjectMeta, instance *entity.Instance) bool {
|
||||||
|
if instance == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
labels := meta.GetLabels()
|
||||||
|
if labels != nil {
|
||||||
|
if labels["app.kubernetes.io/instance"] == instance.Name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
labelKeys := []string{"app", "app.kubernetes.io/name", "app.kubernetes.io/component", "release"}
|
||||||
|
for _, key := range labelKeys {
|
||||||
|
if labels[key] == instance.Name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
annotations := meta.GetAnnotations()
|
||||||
|
if annotations != nil {
|
||||||
|
if annotations["meta.helm.sh/release-name"] == instance.Name {
|
||||||
|
if ns := annotations["meta.helm.sh/release-namespace"]; ns == "" || ns == instance.Namespace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := meta.GetName()
|
||||||
|
if name == instance.Name || strings.HasPrefix(name, instance.Name+"-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
54
backend/internal/adapter/output/k8s/entry_client_test.go
Normal file
54
backend/internal/adapter/output/k8s/entry_client_test.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResourceMatchesInstance(t *testing.T) {
|
||||||
|
instance := &entity.Instance{
|
||||||
|
Name: "demo",
|
||||||
|
Namespace: "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
meta metav1.ObjectMeta
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "matches by standard label",
|
||||||
|
meta: metav1.ObjectMeta{Labels: map[string]string{
|
||||||
|
"app.kubernetes.io/instance": "demo",
|
||||||
|
}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches by helm annotations",
|
||||||
|
meta: metav1.ObjectMeta{Annotations: map[string]string{
|
||||||
|
"meta.helm.sh/release-name": "demo",
|
||||||
|
"meta.helm.sh/release-namespace": "default",
|
||||||
|
}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches by resource name prefix",
|
||||||
|
meta: metav1.ObjectMeta{Name: "demo-nginx"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not match unrelated resource",
|
||||||
|
meta: metav1.ObjectMeta{Name: "other"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
if got := resourceMatchesInstance(tc.meta, instance); got != tc.want {
|
||||||
|
t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
370
backend/internal/adapter/output/k8s/metrics_client.go
Normal file
370
backend/internal/adapter/output/k8s/metrics_client.go
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricsClient 实现从 Kubernetes 集群获取监控指标
|
||||||
|
type MetricsClient struct {
|
||||||
|
clusterRepo repository.ClusterRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetricsClient 创建 MetricsClient
|
||||||
|
func NewMetricsClient(clusterRepo repository.ClusterRepository) *MetricsClient {
|
||||||
|
return &MetricsClient{
|
||||||
|
clusterRepo: clusterRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterMetrics 获取集群监控指标
|
||||||
|
func (c *MetricsClient) GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := c.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Kubernetes 客户端
|
||||||
|
clientset, metricsClient, err := c.createK8sClients(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点列表
|
||||||
|
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有 Pods
|
||||||
|
pods, err := clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点指标(CPU/内存使用情况)
|
||||||
|
nodeMetrics, err := c.getNodeMetricsData(ctx, clientset, metricsClient, nodes.Items)
|
||||||
|
if err != nil {
|
||||||
|
// 如果无法获取 metrics,记录错误但继续
|
||||||
|
fmt.Printf("Warning: failed to get node metrics: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算集群级别汇总
|
||||||
|
metrics := c.aggregateClusterMetrics(cluster, nodes.Items, pods.Items, nodeMetrics)
|
||||||
|
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeMetrics 获取集群节点指标
|
||||||
|
func (c *MetricsClient) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||||
|
cluster, err := c.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, metricsClient, err := c.createK8sClients(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getNodeMetricsData(ctx, clientset, metricsClient, nodes.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createK8sClients 创建 Kubernetes 客户端
|
||||||
|
func (c *MetricsClient) createK8sClients(cluster *entity.Cluster) (*kubernetes.Clientset, *metricsv.Clientset, error) {
|
||||||
|
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.GetKubeConfig()))
|
||||||
|
if err != nil {
|
||||||
|
// 如果无法从 kubeconfig 创建,尝试使用集群配置
|
||||||
|
config = &rest.Config{
|
||||||
|
Host: cluster.Host,
|
||||||
|
TLSClientConfig: rest.TLSClientConfig{
|
||||||
|
CAData: []byte(cluster.CAData),
|
||||||
|
CertData: []byte(cluster.CertData),
|
||||||
|
KeyData: []byte(cluster.KeyData),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create clientset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsClient, err := metricsv.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
// Metrics API 可能不可用,返回 nil 但不报错
|
||||||
|
return clientset, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientset, metricsClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNodeMetricsData 获取节点详细指标
|
||||||
|
func (c *MetricsClient) getNodeMetricsData(
|
||||||
|
ctx context.Context,
|
||||||
|
clientset *kubernetes.Clientset,
|
||||||
|
metricsClient *metricsv.Clientset,
|
||||||
|
nodes []corev1.Node,
|
||||||
|
) ([]*entity.NodeMetrics, error) {
|
||||||
|
result := make([]*entity.NodeMetrics, 0, len(nodes))
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeMetric := &entity.NodeMetrics{
|
||||||
|
NodeName: node.Name,
|
||||||
|
Status: getNodeStatus(&node),
|
||||||
|
Role: getNodeRole(&node),
|
||||||
|
Age: getNodeAge(&node),
|
||||||
|
OSImage: node.Status.NodeInfo.OSImage,
|
||||||
|
KernelVersion: node.Status.NodeInfo.KernelVersion,
|
||||||
|
ContainerRuntime: node.Status.NodeInfo.ContainerRuntimeVersion,
|
||||||
|
KubeletVersion: node.Status.NodeInfo.KubeletVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
cpuCapacity := node.Status.Capacity.Cpu()
|
||||||
|
cpuAllocatable := node.Status.Allocatable.Cpu()
|
||||||
|
nodeMetric.CPUCapacity = fmt.Sprintf("%.2f cores", float64(cpuCapacity.MilliValue())/1000.0)
|
||||||
|
nodeMetric.CPUAllocatable = fmt.Sprintf("%.2f cores", float64(cpuAllocatable.MilliValue())/1000.0)
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
memCapacity := node.Status.Capacity.Memory()
|
||||||
|
memAllocatable := node.Status.Allocatable.Memory()
|
||||||
|
nodeMetric.MemoryCapacity = formatBytes(memCapacity.Value())
|
||||||
|
nodeMetric.MemoryAllocatable = formatBytes(memAllocatable.Value())
|
||||||
|
|
||||||
|
// GPU (从 node allocatable 中查找)
|
||||||
|
if gpu, ok := node.Status.Allocatable["nvidia.com/gpu"]; ok {
|
||||||
|
nodeMetric.GPUCapacity = int(gpu.Value())
|
||||||
|
// 尝试获取 GPU 类型
|
||||||
|
if gpuType, ok := node.Labels["nvidia.com/gpu.product"]; ok {
|
||||||
|
nodeMetric.GPUType = gpuType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Pod 数量
|
||||||
|
pods, err := clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{
|
||||||
|
FieldSelector: fmt.Sprintf("spec.nodeName=%s", node.Name),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
nodeMetric.PodCount = len(pods.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 metrics client,获取实时使用情况
|
||||||
|
if metricsClient != nil {
|
||||||
|
nodeMetricData, err := metricsClient.MetricsV1beta1().NodeMetricses().Get(ctx, node.Name, metav1.GetOptions{})
|
||||||
|
if err == nil {
|
||||||
|
// CPU 使用
|
||||||
|
cpuUsage := nodeMetricData.Usage.Cpu()
|
||||||
|
nodeMetric.CPUUsage = fmt.Sprintf("%.2f cores", float64(cpuUsage.MilliValue())/1000.0)
|
||||||
|
if cpuAllocatable.MilliValue() > 0 {
|
||||||
|
nodeMetric.CPUPercent = float64(cpuUsage.MilliValue()) / float64(cpuAllocatable.MilliValue()) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory 使用
|
||||||
|
memUsage := nodeMetricData.Usage.Memory()
|
||||||
|
nodeMetric.MemoryUsage = formatBytes(memUsage.Value())
|
||||||
|
if memAllocatable.Value() > 0 {
|
||||||
|
nodeMetric.MemoryPercent = float64(memUsage.Value()) / float64(memAllocatable.Value()) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, nodeMetric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateClusterMetrics 聚合集群级别指标
|
||||||
|
func (c *MetricsClient) aggregateClusterMetrics(
|
||||||
|
cluster *entity.Cluster,
|
||||||
|
nodes []corev1.Node,
|
||||||
|
pods []corev1.Pod,
|
||||||
|
nodeMetrics []*entity.NodeMetrics,
|
||||||
|
) *entity.ClusterMetrics {
|
||||||
|
metrics := &entity.ClusterMetrics{
|
||||||
|
ClusterID: cluster.ID,
|
||||||
|
ClusterName: cluster.Name,
|
||||||
|
Status: "healthy",
|
||||||
|
NodeCount: len(nodes),
|
||||||
|
PodCount: len(pods),
|
||||||
|
LastCheck: time.Now(),
|
||||||
|
Nodes: make([]entity.NodeMetrics, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总资源
|
||||||
|
var totalCPU, totalMem, usedCPU, usedMem int64
|
||||||
|
var totalGPU, usedGPU int
|
||||||
|
healthyNodes := 0
|
||||||
|
|
||||||
|
// 单机最大值
|
||||||
|
var maxNodeCPU, maxNodeMem int64
|
||||||
|
var maxNodeGPU int
|
||||||
|
var maxNodeCPUUsage, maxNodeMemUsage, maxNodeGPUUsage float64
|
||||||
|
|
||||||
|
for i, node := range nodes {
|
||||||
|
// CPU
|
||||||
|
cpuCap := node.Status.Capacity.Cpu()
|
||||||
|
totalCPU += cpuCap.MilliValue()
|
||||||
|
if cpuCap.MilliValue() > maxNodeCPU {
|
||||||
|
maxNodeCPU = cpuCap.MilliValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
memCap := node.Status.Capacity.Memory()
|
||||||
|
totalMem += memCap.Value()
|
||||||
|
if memCap.Value() > maxNodeMem {
|
||||||
|
maxNodeMem = memCap.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU
|
||||||
|
if gpu, ok := node.Status.Allocatable["nvidia.com/gpu"]; ok {
|
||||||
|
gpuCount := int(gpu.Value())
|
||||||
|
totalGPU += gpuCount
|
||||||
|
if gpuCount > maxNodeGPU {
|
||||||
|
maxNodeGPU = gpuCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node status
|
||||||
|
if getNodeStatus(&node) == "Ready" {
|
||||||
|
healthyNodes++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 nodeMetrics 获取使用情况
|
||||||
|
if i < len(nodeMetrics) && nodeMetrics[i] != nil {
|
||||||
|
metrics.Nodes = append(metrics.Nodes, *nodeMetrics[i])
|
||||||
|
|
||||||
|
// 更新单机最大使用率
|
||||||
|
if nodeMetrics[i].CPUPercent > maxNodeCPUUsage {
|
||||||
|
maxNodeCPUUsage = nodeMetrics[i].CPUPercent
|
||||||
|
}
|
||||||
|
if nodeMetrics[i].MemoryPercent > maxNodeMemUsage {
|
||||||
|
maxNodeMemUsage = nodeMetrics[i].MemoryPercent
|
||||||
|
}
|
||||||
|
if nodeMetrics[i].GPUPercent > maxNodeGPUUsage {
|
||||||
|
maxNodeGPUUsage = nodeMetrics[i].GPUPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算集群 uptime(简化:使用最老节点的年龄)
|
||||||
|
if len(nodes) > 0 {
|
||||||
|
metrics.Uptime = getNodeAge(&nodes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化总资源
|
||||||
|
metrics.TotalCPU = fmt.Sprintf("%.2f cores", float64(totalCPU)/1000.0)
|
||||||
|
metrics.TotalMemory = formatBytes(totalMem)
|
||||||
|
metrics.TotalGPU = totalGPU
|
||||||
|
|
||||||
|
// 格式化单机最大值
|
||||||
|
metrics.MaxNodeCPU = fmt.Sprintf("%.2f cores", float64(maxNodeCPU)/1000.0)
|
||||||
|
metrics.MaxNodeMemory = formatBytes(maxNodeMem)
|
||||||
|
metrics.MaxNodeGPU = maxNodeGPU
|
||||||
|
metrics.MaxNodeCPUUsage = maxNodeCPUUsage
|
||||||
|
metrics.MaxNodeMemUsage = maxNodeMemUsage
|
||||||
|
metrics.MaxNodeGPUUsage = maxNodeGPUUsage
|
||||||
|
|
||||||
|
// 使用情况(简化处理)
|
||||||
|
if len(nodeMetrics) > 0 {
|
||||||
|
for _, nm := range nodeMetrics {
|
||||||
|
// 解析使用的 CPU 和内存
|
||||||
|
// 这里简化处理,实际应该解析字符串
|
||||||
|
usedCPU += int64(nm.CPUPercent * float64(totalCPU) / 100.0)
|
||||||
|
usedMem += int64(nm.MemoryPercent * float64(totalMem) / 100.0)
|
||||||
|
usedGPU += nm.GPUUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalCPU > 0 {
|
||||||
|
metrics.CPUUsage = float64(usedCPU) / float64(totalCPU) * 100
|
||||||
|
}
|
||||||
|
if totalMem > 0 {
|
||||||
|
metrics.MemoryUsage = float64(usedMem) / float64(totalMem) * 100
|
||||||
|
}
|
||||||
|
if totalGPU > 0 {
|
||||||
|
metrics.GPUUsage = float64(usedGPU) / float64(totalGPU) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.UsedCPU = fmt.Sprintf("%.2f cores", float64(usedCPU)/1000.0)
|
||||||
|
metrics.UsedMemory = formatBytes(usedMem)
|
||||||
|
metrics.UsedGPU = usedGPU
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定集群状态
|
||||||
|
if healthyNodes == len(nodes) {
|
||||||
|
metrics.Status = "healthy"
|
||||||
|
} else if healthyNodes > 0 {
|
||||||
|
metrics.Status = "warning"
|
||||||
|
} else {
|
||||||
|
metrics.Status = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func getNodeStatus(node *corev1.Node) string {
|
||||||
|
for _, condition := range node.Status.Conditions {
|
||||||
|
if condition.Type == corev1.NodeReady {
|
||||||
|
if condition.Status == corev1.ConditionTrue {
|
||||||
|
return "Ready"
|
||||||
|
}
|
||||||
|
return "NotReady"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNodeRole(node *corev1.Node) string {
|
||||||
|
if _, ok := node.Labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||||
|
return "control-plane"
|
||||||
|
}
|
||||||
|
if _, ok := node.Labels["node-role.kubernetes.io/master"]; ok {
|
||||||
|
return "control-plane"
|
||||||
|
}
|
||||||
|
return "worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNodeAge(node *corev1.Node) string {
|
||||||
|
age := time.Since(node.CreationTimestamp.Time)
|
||||||
|
days := int(age.Hours() / 24)
|
||||||
|
hours := int(age.Hours()) % 24
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh", hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
284
backend/internal/adapter/output/oci/mock/oci_client_mock.go
Normal file
284
backend/internal/adapter/output/oci/mock/oci_client_mock.go
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OCIClientMock OCI Registry 客户端 Mock 实现
|
||||||
|
type OCIClientMock struct {
|
||||||
|
// Mock 数据存储
|
||||||
|
repositories map[string][]string // registryID -> []repositoryName
|
||||||
|
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOCIClientMock 创建 Mock 实现
|
||||||
|
func NewOCIClientMock() repository.OCIClient {
|
||||||
|
mock := &OCIClientMock{
|
||||||
|
repositories: make(map[string][]string),
|
||||||
|
artifacts: make(map[string]map[string][]*entity.Artifact),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化一些测试数据
|
||||||
|
mock.initMockData()
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) initMockData() {
|
||||||
|
// Note: This method intentionally left empty
|
||||||
|
// Mock data will be generated dynamically per registry to support any registry ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// initArtifactsForRegistry initializes mock artifacts for a given registry ID
|
||||||
|
func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||||
|
c.artifacts[registryID] = make(map[string][]*entity.Artifact)
|
||||||
|
|
||||||
|
// vllm-serve artifacts (OCI 格式的 Helm Chart)
|
||||||
|
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "charts/vllm-serve",
|
||||||
|
Tag: "0.1.0",
|
||||||
|
Digest: "sha256:abc123def456",
|
||||||
|
Type: entity.ArtifactTypeChart,
|
||||||
|
Size: 12345678,
|
||||||
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "vllm-serve",
|
||||||
|
"org.opencontainers.image.version": "0.1.0",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "charts/vllm-serve",
|
||||||
|
Tag: "0.2.0",
|
||||||
|
Digest: "sha256:xyz789uvw012",
|
||||||
|
Type: entity.ArtifactTypeChart,
|
||||||
|
Size: 13456789,
|
||||||
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "vllm-serve",
|
||||||
|
"org.opencontainers.image.version": "0.2.0",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginx artifacts (OCI 格式的 Helm Chart)
|
||||||
|
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "charts/nginx",
|
||||||
|
Tag: "1.0.0",
|
||||||
|
Digest: "sha256:nginx123456",
|
||||||
|
Type: entity.ArtifactTypeChart,
|
||||||
|
Size: 5678901,
|
||||||
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "nginx",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// redis artifacts (OCI 格式的 Helm Chart)
|
||||||
|
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "charts/redis",
|
||||||
|
Tag: "6.2.0",
|
||||||
|
Digest: "sha256:redis789abc",
|
||||||
|
Type: entity.ArtifactTypeChart,
|
||||||
|
Size: 8901234,
|
||||||
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "redis",
|
||||||
|
"org.opencontainers.image.version": "6.2.0",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// alpine artifacts (Docker Image)
|
||||||
|
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "library/alpine",
|
||||||
|
Tag: "3.18",
|
||||||
|
Digest: "sha256:alpine123",
|
||||||
|
Type: entity.ArtifactTypeImage,
|
||||||
|
Size: 2345678,
|
||||||
|
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "alpine",
|
||||||
|
"org.opencontainers.image.version": "3.18",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: "library/alpine",
|
||||||
|
Tag: "latest",
|
||||||
|
Digest: "sha256:alpine456",
|
||||||
|
Type: entity.ArtifactTypeImage,
|
||||||
|
Size: 2456789,
|
||||||
|
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"org.opencontainers.image.title": "alpine",
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||||
|
// Check if we have cached data for this registry
|
||||||
|
repos, exists := c.repositories[registry.ID]
|
||||||
|
if !exists {
|
||||||
|
// Generate mock data dynamically for any registry
|
||||||
|
repos = []string{
|
||||||
|
"charts/vllm-serve",
|
||||||
|
"charts/nginx",
|
||||||
|
"charts/redis",
|
||||||
|
"library/alpine",
|
||||||
|
}
|
||||||
|
c.repositories[registry.ID] = repos
|
||||||
|
|
||||||
|
// Also initialize artifacts for this registry
|
||||||
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
|
}
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
|
regArtifacts, exists := c.artifacts[registry.ID]
|
||||||
|
if !exists {
|
||||||
|
// Initialize artifacts for this registry if not exists
|
||||||
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
|
regArtifacts = c.artifacts[registry.ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts, exists := regArtifacts[repository]
|
||||||
|
if !exists {
|
||||||
|
return []*entity.Artifact{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用 mediaType 过滤
|
||||||
|
if mediaTypeFilter == "" || mediaTypeFilter == "all" {
|
||||||
|
return artifacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*entity.Artifact, 0)
|
||||||
|
filter := strings.ToLower(strings.TrimSpace(mediaTypeFilter))
|
||||||
|
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
switch filter {
|
||||||
|
case "chart":
|
||||||
|
if artifact.Type == entity.ArtifactTypeChart {
|
||||||
|
filtered = append(filtered, artifact)
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
if artifact.Type == entity.ArtifactTypeImage {
|
||||||
|
filtered = append(filtered, artifact)
|
||||||
|
}
|
||||||
|
case "other":
|
||||||
|
if artifact.Type == entity.ArtifactTypeOther {
|
||||||
|
filtered = append(filtered, artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error) {
|
||||||
|
regArtifacts, exists := c.artifacts[registry.ID]
|
||||||
|
if !exists {
|
||||||
|
// Initialize artifacts for this registry if not exists
|
||||||
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
|
regArtifacts = c.artifacts[registry.ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts, exists := regArtifacts[repository]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 tag 或 digest 查找
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
if artifact.Tag == reference || artifact.Digest == reference {
|
||||||
|
return artifact, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, entity.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) GetValuesSchema(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 schema
|
||||||
|
mockSchema := `{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"replicaCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"repository": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
return mockSchema, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
||||||
|
_, err := c.GetArtifact(ctx, registry, repository, reference)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 实现,不实际下载
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error {
|
||||||
|
// Mock 实现,不实际上传
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) CheckHealth(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
// Mock 实现,总是返回健康
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
468
backend/internal/adapter/output/oci/real/oci_client.go
Normal file
468
backend/internal/adapter/output/oci/real/oci_client.go
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
package real
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"oras.land/oras-go/v2/registry/remote"
|
||||||
|
"oras.land/oras-go/v2/registry/remote/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OCIClient 真实的 OCI 客户端实现(使用 ORAS)
|
||||||
|
type OCIClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOCIClient 创建真实的 OCI 客户端
|
||||||
|
func NewOCIClient() repository.OCIClient {
|
||||||
|
return &OCIClient{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegistry 创建 ORAS Registry 客户端
|
||||||
|
func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error) {
|
||||||
|
// 解析 Registry URL
|
||||||
|
registryURL := strings.TrimPrefix(reg.URL, "https://")
|
||||||
|
registryURL = strings.TrimPrefix(registryURL, "http://")
|
||||||
|
|
||||||
|
registry, err := remote.NewRegistry(registryURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create registry client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置认证
|
||||||
|
if reg.Username != "" && reg.Password != "" {
|
||||||
|
registry.Client = &auth.Client{
|
||||||
|
Client: c.httpClient,
|
||||||
|
Credential: auth.StaticCredential(registryURL, auth.Credential{
|
||||||
|
Username: reg.Username,
|
||||||
|
Password: reg.Password,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 PlainHTTP(如果是 insecure)
|
||||||
|
registry.PlainHTTP = reg.Insecure
|
||||||
|
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepositories 列出 Registry 中的所有 repositories
|
||||||
|
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)
|
||||||
|
|
||||||
|
err = reg.Repositories(ctx, "", func(repos []string) error {
|
||||||
|
repositories = append(repositories, repos...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list repositories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||||
|
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||||
|
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
|
reg, err := c.getRegistry(registry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := reg.Repository(ctx, repository)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := make([]*entity.Artifact, 0)
|
||||||
|
|
||||||
|
err = repo.Tags(ctx, "", func(tags []string) error {
|
||||||
|
for _, tag := range tags {
|
||||||
|
// 获取 manifest 以获取更多信息
|
||||||
|
desc, err := repo.Resolve(ctx, tag)
|
||||||
|
if err != nil {
|
||||||
|
// 跳过无法解析的 tag
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact := &entity.Artifact{
|
||||||
|
Repository: repository,
|
||||||
|
Tag: tag,
|
||||||
|
Digest: desc.Digest.String(),
|
||||||
|
MediaType: desc.MediaType,
|
||||||
|
Size: desc.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取 config.mediaType 以更准确判断类型
|
||||||
|
if manifestBytes, err := repo.Fetch(ctx, desc); err == nil {
|
||||||
|
defer manifestBytes.Close()
|
||||||
|
if manifestData, err := io.ReadAll(manifestBytes); err == nil {
|
||||||
|
var manifest map[string]interface{}
|
||||||
|
if err := json.Unmarshal(manifestData, &manifest); err == nil {
|
||||||
|
// 获取 config.mediaType
|
||||||
|
if config, ok := manifest["config"].(map[string]interface{}); ok {
|
||||||
|
if configMediaType, ok := config["mediaType"].(string); ok {
|
||||||
|
artifact.ConfigType = configMediaType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用智能类型判断(综合多种信息)
|
||||||
|
artifact.DetermineType()
|
||||||
|
|
||||||
|
// 应用 mediaType 过滤
|
||||||
|
if c.shouldIncludeArtifact(artifact, mediaTypeFilter) {
|
||||||
|
artifacts = append(artifacts, artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list artifacts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldIncludeArtifact 判断是否应该包含该 artifact
|
||||||
|
func (c *OCIClient) shouldIncludeArtifact(artifact *entity.Artifact, filter string) bool {
|
||||||
|
// 默认或 "all" 返回所有
|
||||||
|
if filter == "" || filter == "all" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = strings.ToLower(strings.TrimSpace(filter))
|
||||||
|
|
||||||
|
switch filter {
|
||||||
|
case "chart":
|
||||||
|
// 只返回 Helm Charts
|
||||||
|
return artifact.Type == entity.ArtifactTypeChart
|
||||||
|
case "image":
|
||||||
|
// 返回 Docker 或 OCI images
|
||||||
|
return artifact.Type == entity.ArtifactTypeImage
|
||||||
|
case "other":
|
||||||
|
// 返回其他类型
|
||||||
|
return artifact.Type == entity.ArtifactTypeOther
|
||||||
|
default:
|
||||||
|
// 未知的 filter,返回所有
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifact 获取指定 artifact 的详细信息
|
||||||
|
func (c *OCIClient) GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error) {
|
||||||
|
reg, err := c.getRegistry(registry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := reg.Repository(ctx, repository)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 reference
|
||||||
|
desc, err := repo.Resolve(ctx, reference)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 manifest
|
||||||
|
manifestBytes, err := repo.Fetch(ctx, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch manifest: %w", err)
|
||||||
|
}
|
||||||
|
defer manifestBytes.Close()
|
||||||
|
|
||||||
|
manifestData, err := io.ReadAll(manifestBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 manifest 获取配置信息
|
||||||
|
var manifest map[string]interface{}
|
||||||
|
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact := &entity.Artifact{
|
||||||
|
Repository: repository,
|
||||||
|
Tag: reference,
|
||||||
|
Digest: desc.Digest.String(),
|
||||||
|
MediaType: desc.MediaType,
|
||||||
|
Size: desc.Size,
|
||||||
|
Annotations: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 config.mediaType 和 annotations
|
||||||
|
if config, ok := manifest["config"].(map[string]interface{}); ok {
|
||||||
|
// 获取 config.mediaType(用于准确的类型判断)
|
||||||
|
if configMediaType, ok := config["mediaType"].(string); ok {
|
||||||
|
artifact.ConfigType = configMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 annotations
|
||||||
|
if annotations, ok := config["annotations"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range annotations {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
artifact.Annotations[k] = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用智能类型判断(综合 ConfigType, Annotations, Repository 名称等)
|
||||||
|
artifact.DetermineType()
|
||||||
|
|
||||||
|
return artifact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||||
|
func (c *OCIClient) GetValuesSchema(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先查找是否存在独立的 values schema layer(一些 registry 会将 values.schema.json 作为单独的 layer 存储)
|
||||||
|
var valuesSchemaLayer *ocispec.Descriptor
|
||||||
|
for i := range manifest.Layers {
|
||||||
|
layer := manifest.Layers[i]
|
||||||
|
mediaType := strings.ToLower(layer.MediaType)
|
||||||
|
|
||||||
|
if strings.Contains(mediaType, "helm.values.schema") ||
|
||||||
|
strings.Contains(mediaType, "values.schema") {
|
||||||
|
valuesSchemaLayer = &manifest.Layers[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在独立的 values schema layer,直接返回
|
||||||
|
if valuesSchemaLayer != nil {
|
||||||
|
reader, err := repo.Fetch(ctx, *valuesSchemaLayer)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch values schema layer: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read values schema layer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", entity.ErrValuesSchemaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:查找 Helm Chart layer(tar+gzip 包含 chart 内容)并从中读取 values.schema.json
|
||||||
|
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.ErrValuesSchemaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(header.Name, "values.schema.json") {
|
||||||
|
data, err := io.ReadAll(tarReader)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read values.schema.json: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", entity.ErrValuesSchemaNotFound
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", entity.ErrValuesSchemaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullArtifact 下载 artifact 到本地
|
||||||
|
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath 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
|
||||||
|
desc, err := repo.Resolve(ctx, reference)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 manifest 内容
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = &layer
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chartLayer == nil {
|
||||||
|
return fmt.Errorf("helm chart layer not found in manifest")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := repo.Fetch(ctx, *chartLayer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch chart layer: %w", err)
|
||||||
|
}
|
||||||
|
defer content.Close()
|
||||||
|
|
||||||
|
// 确保目标目录存在
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
file, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(file, content); err != nil {
|
||||||
|
return fmt.Errorf("failed to write artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushArtifact 推送 artifact 到 Registry
|
||||||
|
func (c *OCIClient) PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error {
|
||||||
|
// 这是一个简化实现
|
||||||
|
// 实际应该实现完整的 OCI artifact push 流程
|
||||||
|
return fmt.Errorf("push artifact not fully implemented yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHealth 检查 Registry 健康状态
|
||||||
|
func (c *OCIClient) CheckHealth(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
reg, err := c.getRegistry(registry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 ping registry
|
||||||
|
err = reg.Ping(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("registry health check failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterRepositoryMock 集群仓储 Mock 实现(内存存储,支持加密)
|
||||||
|
type ClusterRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clusters map[string]*entity.Cluster // key: cluster ID
|
||||||
|
encryptor crypto.Encryptor // 加密器
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterRepositoryMock 创建 Mock 实现
|
||||||
|
func NewClusterRepositoryMock(encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||||
|
return &ClusterRepositoryMock{
|
||||||
|
clusters: make(map[string]*entity.Cluster),
|
||||||
|
encryptor: encryptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查名称是否已存在
|
||||||
|
for _, c := range r.clusters {
|
||||||
|
if c.Name == cluster.Name {
|
||||||
|
return entity.ErrClusterExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 模式:如果没有提供认证信息,自动填充默认的 Mock 证书
|
||||||
|
if (cluster.CertData == "" || cluster.KeyData == "") && cluster.Token == "" {
|
||||||
|
cluster.CAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="
|
||||||
|
cluster.CertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
|
||||||
|
cluster.KeyData = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密敏感数据后存储
|
||||||
|
encryptedCluster := r.encryptCluster(cluster)
|
||||||
|
r.clusters[cluster.ID] = encryptedCluster
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
cluster, exists := r.clusters[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
return r.decryptCluster(cluster), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, cluster := range r.clusters {
|
||||||
|
if cluster.Name == name {
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
return r.decryptCluster(cluster), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.clusters[cluster.ID]; !exists {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密敏感数据后存储
|
||||||
|
encryptedCluster := r.encryptCluster(cluster)
|
||||||
|
r.clusters[cluster.ID] = encryptedCluster
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.clusters[id]; !exists {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.clusters, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
clusters := make([]*entity.Cluster, 0, len(r.clusters))
|
||||||
|
for _, cluster := range r.clusters {
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
clusters = append(clusters, r.decryptCluster(cluster))
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptCluster 加密 Cluster 的敏感数据
|
||||||
|
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||||
|
encrypted := *cluster // 复制
|
||||||
|
|
||||||
|
// 加密证书数据
|
||||||
|
if cluster.CAData != "" && !crypto.IsEncrypted(cluster.CAData) {
|
||||||
|
if encryptedData, err := r.encryptor.Encrypt(cluster.CAData); err == nil {
|
||||||
|
encrypted.CAData = encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.CertData != "" && !crypto.IsEncrypted(cluster.CertData) {
|
||||||
|
if encryptedData, err := r.encryptor.Encrypt(cluster.CertData); err == nil {
|
||||||
|
encrypted.CertData = encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.KeyData != "" && !crypto.IsEncrypted(cluster.KeyData) {
|
||||||
|
if encryptedData, err := r.encryptor.Encrypt(cluster.KeyData); err == nil {
|
||||||
|
encrypted.KeyData = encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.Token != "" && !crypto.IsEncrypted(cluster.Token) {
|
||||||
|
if encryptedData, err := r.encryptor.Encrypt(cluster.Token); err == nil {
|
||||||
|
encrypted.Token = encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptCluster 解密 Cluster 的敏感数据
|
||||||
|
func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||||
|
decrypted := *cluster // 复制
|
||||||
|
|
||||||
|
// 解密证书数据
|
||||||
|
if cluster.CAData != "" && crypto.IsEncrypted(cluster.CAData) {
|
||||||
|
if decryptedData, err := r.encryptor.Decrypt(cluster.CAData); err == nil {
|
||||||
|
decrypted.CAData = decryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.CertData != "" && crypto.IsEncrypted(cluster.CertData) {
|
||||||
|
if decryptedData, err := r.encryptor.Decrypt(cluster.CertData); err == nil {
|
||||||
|
decrypted.CertData = decryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.KeyData != "" && crypto.IsEncrypted(cluster.KeyData) {
|
||||||
|
if decryptedData, err := r.encryptor.Decrypt(cluster.KeyData); err == nil {
|
||||||
|
decrypted.KeyData = decryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cluster.Token != "" && crypto.IsEncrypted(cluster.Token) {
|
||||||
|
if decryptedData, err := r.encryptor.Decrypt(cluster.Token); err == nil {
|
||||||
|
decrypted.Token = decryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &decrypted
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceRepositoryMock 实例仓储 Mock 实现(内存存储)
|
||||||
|
type InstanceRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
instances map[string]*entity.Instance // key: instance ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstanceRepositoryMock 创建 Mock 实现
|
||||||
|
func NewInstanceRepositoryMock() repository.InstanceRepository {
|
||||||
|
return &InstanceRepositoryMock{
|
||||||
|
instances: make(map[string]*entity.Instance),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查同一集群中名称是否已存在
|
||||||
|
for _, inst := range r.instances {
|
||||||
|
if inst.ClusterID == instance.ClusterID && inst.Name == instance.Name {
|
||||||
|
return entity.ErrInstanceExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.instances[instance.ID] = instance
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
instance, exists := r.instances[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, instance := range r.instances {
|
||||||
|
if instance.ClusterID == clusterID && instance.Name == name {
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.instances[instance.ID]; !exists {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.instances[instance.ID] = instance
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.instances[id]; !exists {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.instances, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0)
|
||||||
|
for _, instance := range r.instances {
|
||||||
|
if instance.ClusterID == clusterID {
|
||||||
|
instances = append(instances, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0, len(r.instances))
|
||||||
|
for _, instance := range r.instances {
|
||||||
|
instances = append(instances, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryRepositoryMock Registry 仓储 Mock 实现(内存存储,支持加密)
|
||||||
|
type RegistryRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
registries map[string]*entity.Registry // key: registry ID
|
||||||
|
encryptor crypto.Encryptor // 加密器
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistryRepositoryMock 创建 Mock 实现
|
||||||
|
func NewRegistryRepositoryMock(encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||||
|
return &RegistryRepositoryMock{
|
||||||
|
registries: make(map[string]*entity.Registry),
|
||||||
|
encryptor: encryptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查名称是否已存在
|
||||||
|
for _, reg := range r.registries {
|
||||||
|
if reg.Name == registry.Name {
|
||||||
|
return entity.ErrRegistryExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密敏感数据后存储
|
||||||
|
encryptedRegistry := r.encryptRegistry(registry)
|
||||||
|
r.registries[registry.ID] = encryptedRegistry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
registry, exists := r.registries[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
return r.decryptRegistry(registry), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, registry := range r.registries {
|
||||||
|
if registry.Name == name {
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
return r.decryptRegistry(registry), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.registries[registry.ID]; !exists {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密敏感数据后存储
|
||||||
|
encryptedRegistry := r.encryptRegistry(registry)
|
||||||
|
r.registries[registry.ID] = encryptedRegistry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.registries[id]; !exists {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.registries, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepositoryMock) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
registries := make([]*entity.Registry, 0, len(r.registries))
|
||||||
|
for _, registry := range r.registries {
|
||||||
|
// 解密敏感数据后返回
|
||||||
|
registries = append(registries, r.decryptRegistry(registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
return registries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptRegistry 加密 Registry 的敏感数据
|
||||||
|
func (r *RegistryRepositoryMock) encryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||||
|
encrypted := *registry // 复制
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
if registry.Password != "" && !crypto.IsEncrypted(registry.Password) {
|
||||||
|
if encryptedPassword, err := r.encryptor.Encrypt(registry.Password); err == nil {
|
||||||
|
encrypted.Password = encryptedPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptRegistry 解密 Registry 的敏感数据
|
||||||
|
func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||||
|
decrypted := *registry // 复制
|
||||||
|
|
||||||
|
// 解密密码
|
||||||
|
if registry.Password != "" && crypto.IsEncrypted(registry.Password) {
|
||||||
|
if decryptedPassword, err := r.encryptor.Decrypt(registry.Password); err == nil {
|
||||||
|
decrypted.Password = decryptedPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &decrypted
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepositoryMock 用户仓储 Mock 实现(内存存储)
|
||||||
|
type UserRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
users map[string]*entity.User // key: user ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepositoryMock 创建 Mock 实现
|
||||||
|
func NewUserRepositoryMock() repository.UserRepository {
|
||||||
|
return &UserRepositoryMock{
|
||||||
|
users: make(map[string]*entity.User),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
for _, u := range r.users {
|
||||||
|
if u.Username == user.Username {
|
||||||
|
return entity.ErrUserExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.users[user.ID] = user
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
user, exists := r.users[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, user := range r.users {
|
||||||
|
if user.Username == username {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.users[user.ID]; !exists {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.users[user.ID] = user
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.users[id]; !exists {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.users, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
users := make([]*entity.User, 0, len(r.users))
|
||||||
|
for _, user := range r.users {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,337 @@
|
|||||||
|
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"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterRepository PostgreSQL 集群仓储实现
|
||||||
|
type ClusterRepository struct {
|
||||||
|
db *DB
|
||||||
|
encryptor crypto.Encryptor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterRepository 创建 PostgreSQL 集群仓储
|
||||||
|
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||||
|
return &ClusterRepository{
|
||||||
|
db: db,
|
||||||
|
encryptor: encryptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建集群
|
||||||
|
func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
if cluster.ID == "" {
|
||||||
|
cluster.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密敏感数据
|
||||||
|
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO clusters (id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
|
cluster.ID,
|
||||||
|
cluster.Name,
|
||||||
|
cluster.Host,
|
||||||
|
encryptedCAData,
|
||||||
|
encryptedCertData,
|
||||||
|
encryptedKeyData,
|
||||||
|
encryptedToken,
|
||||||
|
cluster.Description,
|
||||||
|
cluster.CreatedAt,
|
||||||
|
cluster.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取集群
|
||||||
|
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
|
||||||
|
FROM clusters
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
cluster := &entity.Cluster{}
|
||||||
|
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&cluster.ID,
|
||||||
|
&cluster.Name,
|
||||||
|
&cluster.Host,
|
||||||
|
&encryptedCAData,
|
||||||
|
&encryptedCertData,
|
||||||
|
&encryptedKeyData,
|
||||||
|
&encryptedToken,
|
||||||
|
&cluster.Description,
|
||||||
|
&cluster.CreatedAt,
|
||||||
|
&cluster.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据
|
||||||
|
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName 根据名称获取集群
|
||||||
|
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
|
||||||
|
FROM clusters
|
||||||
|
WHERE name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
cluster := &entity.Cluster{}
|
||||||
|
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||||
|
&cluster.ID,
|
||||||
|
&cluster.Name,
|
||||||
|
&cluster.Host,
|
||||||
|
&encryptedCAData,
|
||||||
|
&encryptedCertData,
|
||||||
|
&encryptedKeyData,
|
||||||
|
&encryptedToken,
|
||||||
|
&cluster.Description,
|
||||||
|
&cluster.CreatedAt,
|
||||||
|
&cluster.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据
|
||||||
|
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新集群
|
||||||
|
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
cluster.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// 加密敏感数据
|
||||||
|
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE clusters
|
||||||
|
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
|
||||||
|
token = $6, description = $7, updated_at = $8
|
||||||
|
WHERE id = $9
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
cluster.Name,
|
||||||
|
cluster.Host,
|
||||||
|
encryptedCAData,
|
||||||
|
encryptedCertData,
|
||||||
|
encryptedKeyData,
|
||||||
|
encryptedToken,
|
||||||
|
cluster.Description,
|
||||||
|
cluster.UpdatedAt,
|
||||||
|
cluster.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除集群
|
||||||
|
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM clusters WHERE id = $1`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出所有集群
|
||||||
|
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
|
||||||
|
FROM clusters
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
clusters := make([]*entity.Cluster, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
cluster := &entity.Cluster{}
|
||||||
|
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&cluster.ID,
|
||||||
|
&cluster.Name,
|
||||||
|
&cluster.Host,
|
||||||
|
&encryptedCAData,
|
||||||
|
&encryptedCertData,
|
||||||
|
&encryptedKeyData,
|
||||||
|
&encryptedToken,
|
||||||
|
&cluster.Description,
|
||||||
|
&cluster.CreatedAt,
|
||||||
|
&cluster.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感数据
|
||||||
|
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusters = append(clusters, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters, nil
|
||||||
|
}
|
||||||
|
|
||||||
135
backend/internal/adapter/output/persistence/postgres/db.go
Normal file
135
backend/internal/adapter/output/persistence/postgres/db.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB 数据库连接包装器
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDB 创建新的数据库连接
|
||||||
|
func NewDB(connString string) (*DB, error) {
|
||||||
|
if connString == "" {
|
||||||
|
return nil, fmt.Errorf("database connection string cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := sql.Open("postgres", connString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置连接池
|
||||||
|
conn.SetMaxOpenConns(25)
|
||||||
|
conn.SetMaxIdleConns(5)
|
||||||
|
conn.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭数据库连接
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
if db.conn != nil {
|
||||||
|
return db.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConn 获取底层连接(用于事务等高级操作)
|
||||||
|
func (db *DB) GetConn() *sql.DB {
|
||||||
|
return db.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitSchema 初始化数据库 schema
|
||||||
|
func (db *DB) InitSchema() error {
|
||||||
|
schema := `
|
||||||
|
-- Users 表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
|
-- Clusters 表
|
||||||
|
CREATE TABLE IF NOT EXISTS clusters (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
ca_data TEXT,
|
||||||
|
cert_data TEXT,
|
||||||
|
key_data TEXT,
|
||||||
|
token TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
|
||||||
|
|
||||||
|
-- Registries 表
|
||||||
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
username VARCHAR(255),
|
||||||
|
password TEXT,
|
||||||
|
insecure 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_registries_name ON registries(name);
|
||||||
|
|
||||||
|
-- Instances 表
|
||||||
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
cluster_id VARCHAR(36) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
namespace VARCHAR(255) NOT NULL,
|
||||||
|
registry_id VARCHAR(36) NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
chart VARCHAR(255) NOT NULL,
|
||||||
|
version VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
values JSONB,
|
||||||
|
values_yaml TEXT,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
status_reason TEXT,
|
||||||
|
last_operation VARCHAR(50),
|
||||||
|
last_error TEXT,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_cluster FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_registry FOREIGN KEY (registry_id) REFERENCES registries(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := db.conn.Exec(schema)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,433 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceRepository PostgreSQL 实例仓储实现
|
||||||
|
type InstanceRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstanceRepository 创建 PostgreSQL 实例仓储
|
||||||
|
func NewInstanceRepository(db *DB) repository.InstanceRepository {
|
||||||
|
return &InstanceRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建实例
|
||||||
|
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
if instance.ID == "" {
|
||||||
|
instance.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 Values 转换为 JSON
|
||||||
|
valuesJSON, err := json.Marshal(instance.Values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal values: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO instances (id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
|
revision, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
|
instance.ID,
|
||||||
|
instance.ClusterID,
|
||||||
|
instance.Name,
|
||||||
|
instance.Namespace,
|
||||||
|
instance.RegistryID,
|
||||||
|
instance.Repository,
|
||||||
|
instance.Chart,
|
||||||
|
instance.Version,
|
||||||
|
instance.Description,
|
||||||
|
valuesJSON,
|
||||||
|
instance.ValuesYAML,
|
||||||
|
instance.Status,
|
||||||
|
instance.StatusReason,
|
||||||
|
instance.LastOperation,
|
||||||
|
instance.LastError,
|
||||||
|
instance.Revision,
|
||||||
|
instance.CreatedAt,
|
||||||
|
instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取实例
|
||||||
|
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
|
revision, created_at, updated_at
|
||||||
|
FROM instances
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
instance := &entity.Instance{}
|
||||||
|
var (
|
||||||
|
valuesJSON []byte
|
||||||
|
statusReason sql.NullString
|
||||||
|
lastOperation sql.NullString
|
||||||
|
lastError sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&instance.ID,
|
||||||
|
&instance.ClusterID,
|
||||||
|
&instance.Name,
|
||||||
|
&instance.Namespace,
|
||||||
|
&instance.RegistryID,
|
||||||
|
&instance.Repository,
|
||||||
|
&instance.Chart,
|
||||||
|
&instance.Version,
|
||||||
|
&instance.Description,
|
||||||
|
&valuesJSON,
|
||||||
|
&instance.ValuesYAML,
|
||||||
|
&instance.Status,
|
||||||
|
&statusReason,
|
||||||
|
&lastOperation,
|
||||||
|
&lastError,
|
||||||
|
&instance.Revision,
|
||||||
|
&instance.CreatedAt,
|
||||||
|
&instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JSON Values
|
||||||
|
if len(valuesJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusReason.Valid {
|
||||||
|
instance.StatusReason = statusReason.String
|
||||||
|
}
|
||||||
|
if lastOperation.Valid {
|
||||||
|
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
instance.LastError = lastError.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||||
|
func (r *InstanceRepository) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
|
revision, created_at, updated_at
|
||||||
|
FROM instances
|
||||||
|
WHERE cluster_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
instance := &entity.Instance{}
|
||||||
|
var (
|
||||||
|
valuesJSON []byte
|
||||||
|
statusReason sql.NullString
|
||||||
|
lastOperation sql.NullString
|
||||||
|
lastError sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, clusterID, name).Scan(
|
||||||
|
&instance.ID,
|
||||||
|
&instance.ClusterID,
|
||||||
|
&instance.Name,
|
||||||
|
&instance.Namespace,
|
||||||
|
&instance.RegistryID,
|
||||||
|
&instance.Repository,
|
||||||
|
&instance.Chart,
|
||||||
|
&instance.Version,
|
||||||
|
&instance.Description,
|
||||||
|
&valuesJSON,
|
||||||
|
&instance.ValuesYAML,
|
||||||
|
&instance.Status,
|
||||||
|
&statusReason,
|
||||||
|
&lastOperation,
|
||||||
|
&lastError,
|
||||||
|
&instance.Revision,
|
||||||
|
&instance.CreatedAt,
|
||||||
|
&instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JSON Values
|
||||||
|
if len(valuesJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusReason.Valid {
|
||||||
|
instance.StatusReason = statusReason.String
|
||||||
|
}
|
||||||
|
if lastOperation.Valid {
|
||||||
|
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
instance.LastError = lastError.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新实例
|
||||||
|
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
instance.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// 将 Values 转换为 JSON
|
||||||
|
valuesJSON, err := json.Marshal(instance.Values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal values: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE instances
|
||||||
|
SET cluster_id = $1, name = $2, namespace = $3, registry_id = $4, repository = $5,
|
||||||
|
chart = $6, version = $7, description = $8, values = $9, values_yaml = $10,
|
||||||
|
status = $11, status_reason = $12, last_operation = $13, last_error = $14,
|
||||||
|
revision = $15, updated_at = $16
|
||||||
|
WHERE id = $17
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
instance.ClusterID,
|
||||||
|
instance.Name,
|
||||||
|
instance.Namespace,
|
||||||
|
instance.RegistryID,
|
||||||
|
instance.Repository,
|
||||||
|
instance.Chart,
|
||||||
|
instance.Version,
|
||||||
|
instance.Description,
|
||||||
|
valuesJSON,
|
||||||
|
instance.ValuesYAML,
|
||||||
|
instance.Status,
|
||||||
|
instance.StatusReason,
|
||||||
|
instance.LastOperation,
|
||||||
|
instance.LastError,
|
||||||
|
instance.Revision,
|
||||||
|
instance.UpdatedAt,
|
||||||
|
instance.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除实例
|
||||||
|
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM instances WHERE id = $1`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByCluster 列出指定集群的所有实例
|
||||||
|
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
|
revision, created_at, updated_at
|
||||||
|
FROM instances
|
||||||
|
WHERE cluster_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list instances: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
instance := &entity.Instance{}
|
||||||
|
var (
|
||||||
|
valuesJSON []byte
|
||||||
|
statusReason sql.NullString
|
||||||
|
lastOperation sql.NullString
|
||||||
|
lastError sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&instance.ID,
|
||||||
|
&instance.ClusterID,
|
||||||
|
&instance.Name,
|
||||||
|
&instance.Namespace,
|
||||||
|
&instance.RegistryID,
|
||||||
|
&instance.Repository,
|
||||||
|
&instance.Chart,
|
||||||
|
&instance.Version,
|
||||||
|
&instance.Description,
|
||||||
|
&valuesJSON,
|
||||||
|
&instance.ValuesYAML,
|
||||||
|
&instance.Status,
|
||||||
|
&statusReason,
|
||||||
|
&lastOperation,
|
||||||
|
&lastError,
|
||||||
|
&instance.Revision,
|
||||||
|
&instance.CreatedAt,
|
||||||
|
&instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JSON Values
|
||||||
|
if len(valuesJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusReason.Valid {
|
||||||
|
instance.StatusReason = statusReason.String
|
||||||
|
}
|
||||||
|
if lastOperation.Valid {
|
||||||
|
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
instance.LastError = lastError.String
|
||||||
|
}
|
||||||
|
|
||||||
|
instances = append(instances, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出所有实例
|
||||||
|
func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
|
revision, created_at, updated_at
|
||||||
|
FROM instances
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list instances: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
instances := make([]*entity.Instance, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
instance := &entity.Instance{}
|
||||||
|
var (
|
||||||
|
valuesJSON []byte
|
||||||
|
statusReason sql.NullString
|
||||||
|
lastOperation sql.NullString
|
||||||
|
lastError sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&instance.ID,
|
||||||
|
&instance.ClusterID,
|
||||||
|
&instance.Name,
|
||||||
|
&instance.Namespace,
|
||||||
|
&instance.RegistryID,
|
||||||
|
&instance.Repository,
|
||||||
|
&instance.Chart,
|
||||||
|
&instance.Version,
|
||||||
|
&instance.Description,
|
||||||
|
&valuesJSON,
|
||||||
|
&instance.ValuesYAML,
|
||||||
|
&instance.Status,
|
||||||
|
&statusReason,
|
||||||
|
&lastOperation,
|
||||||
|
&lastError,
|
||||||
|
&instance.Revision,
|
||||||
|
&instance.CreatedAt,
|
||||||
|
&instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JSON Values
|
||||||
|
if len(valuesJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusReason.Valid {
|
||||||
|
instance.StatusReason = statusReason.String
|
||||||
|
}
|
||||||
|
if lastOperation.Valid {
|
||||||
|
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
instance.LastError = lastError.String
|
||||||
|
}
|
||||||
|
|
||||||
|
instances = append(instances, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,257 @@
|
|||||||
|
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"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryRepository PostgreSQL Registry 仓储实现
|
||||||
|
type RegistryRepository struct {
|
||||||
|
db *DB
|
||||||
|
encryptor crypto.Encryptor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistryRepository 创建 PostgreSQL Registry 仓储
|
||||||
|
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||||
|
return &RegistryRepository{
|
||||||
|
db: db,
|
||||||
|
encryptor: encryptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建 Registry
|
||||||
|
func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
if registry.ID == "" {
|
||||||
|
registry.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO registries (id, name, url, description, username, password, insecure, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
|
registry.ID,
|
||||||
|
registry.Name,
|
||||||
|
registry.URL,
|
||||||
|
registry.Description,
|
||||||
|
registry.Username,
|
||||||
|
encryptedPassword,
|
||||||
|
registry.Insecure,
|
||||||
|
registry.CreatedAt,
|
||||||
|
registry.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取 Registry
|
||||||
|
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
||||||
|
FROM registries
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
registry := &entity.Registry{}
|
||||||
|
var encryptedPassword string
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
®istry.ID,
|
||||||
|
®istry.Name,
|
||||||
|
®istry.URL,
|
||||||
|
®istry.Description,
|
||||||
|
®istry.Username,
|
||||||
|
&encryptedPassword,
|
||||||
|
®istry.Insecure,
|
||||||
|
®istry.CreatedAt,
|
||||||
|
®istry.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密密码
|
||||||
|
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName 根据名称获取 Registry
|
||||||
|
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
||||||
|
FROM registries
|
||||||
|
WHERE name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
registry := &entity.Registry{}
|
||||||
|
var encryptedPassword string
|
||||||
|
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||||
|
®istry.ID,
|
||||||
|
®istry.Name,
|
||||||
|
®istry.URL,
|
||||||
|
®istry.Description,
|
||||||
|
®istry.Username,
|
||||||
|
&encryptedPassword,
|
||||||
|
®istry.Insecure,
|
||||||
|
®istry.CreatedAt,
|
||||||
|
®istry.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密密码
|
||||||
|
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新 Registry
|
||||||
|
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
registry.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE registries
|
||||||
|
SET name = $1, url = $2, description = $3, username = $4, password = $5,
|
||||||
|
insecure = $6, updated_at = $7
|
||||||
|
WHERE id = $8
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
registry.Name,
|
||||||
|
registry.URL,
|
||||||
|
registry.Description,
|
||||||
|
registry.Username,
|
||||||
|
encryptedPassword,
|
||||||
|
registry.Insecure,
|
||||||
|
registry.UpdatedAt,
|
||||||
|
registry.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除 Registry
|
||||||
|
func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM registries WHERE id = $1`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出所有 Registries
|
||||||
|
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
||||||
|
FROM registries
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list registries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
registries := make([]*entity.Registry, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
registry := &entity.Registry{}
|
||||||
|
var encryptedPassword string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
®istry.ID,
|
||||||
|
®istry.Name,
|
||||||
|
®istry.URL,
|
||||||
|
®istry.Description,
|
||||||
|
®istry.Username,
|
||||||
|
&encryptedPassword,
|
||||||
|
®istry.Insecure,
|
||||||
|
®istry.CreatedAt,
|
||||||
|
®istry.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密密码
|
||||||
|
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registries = append(registries, registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return registries, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepository PostgreSQL 用户仓储实现
|
||||||
|
type UserRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository 创建 PostgreSQL 用户仓储
|
||||||
|
func NewUserRepository(db *DB) repository.UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建用户
|
||||||
|
func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
||||||
|
if user.ID == "" {
|
||||||
|
user.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO users (id, username, password_hash, email, revoked_after, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
user.ID,
|
||||||
|
user.Username,
|
||||||
|
user.PasswordHash,
|
||||||
|
user.Email,
|
||||||
|
user.RevokedAfter,
|
||||||
|
user.CreatedAt,
|
||||||
|
user.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取用户
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
user := &entity.User{}
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.PasswordHash,
|
||||||
|
&user.Email,
|
||||||
|
&user.RevokedAfter,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUsername 根据用户名获取用户
|
||||||
|
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
user := &entity.User{}
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.PasswordHash,
|
||||||
|
&user.Email,
|
||||||
|
&user.RevokedAfter,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新用户
|
||||||
|
func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE users
|
||||||
|
SET username = $1, password_hash = $2, email = $3, revoked_after = $4, updated_at = $5
|
||||||
|
WHERE id = $6
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
user.Username,
|
||||||
|
user.PasswordHash,
|
||||||
|
user.Email,
|
||||||
|
user.RevokedAfter,
|
||||||
|
user.UpdatedAt,
|
||||||
|
user.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户
|
||||||
|
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM users WHERE id = $1`
|
||||||
|
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出所有用户
|
||||||
|
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list 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.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
|
||||||
|
}
|
||||||
|
|
||||||
137
backend/internal/bootstrap/config.go
Normal file
137
backend/internal/bootstrap/config.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BootstrapConfig 预注入配置
|
||||||
|
type BootstrapConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Users []UserSeed `json:"users"`
|
||||||
|
Registries []RegistrySeed `json:"registries"`
|
||||||
|
Clusters []ClusterSeed `json:"clusters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSeed 用户预注入数据
|
||||||
|
type UserSeed struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrySeed Registry 预注入数据
|
||||||
|
type RegistrySeed struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterSeed Cluster 预注入数据
|
||||||
|
type ClusterSeed struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CAData string `json:"ca_data"`
|
||||||
|
CertData string `json:"cert_data"`
|
||||||
|
KeyData string `json:"key_data"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBootstrapConfig 加载预注入配置
|
||||||
|
// 支持从文件或环境变量加载
|
||||||
|
//
|
||||||
|
// 加载优先级:
|
||||||
|
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
||||||
|
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
||||||
|
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据
|
||||||
|
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||||
|
// 1. 优先从环境变量加载
|
||||||
|
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
||||||
|
var config BootstrapConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse BOOTSTRAP_CONFIG_JSON: %w", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查适配器模式
|
||||||
|
adapterMode := os.Getenv("ADAPTER_MODE")
|
||||||
|
|
||||||
|
// Mock 模式: 使用配置文件(假数据)
|
||||||
|
if adapterMode == "mock" {
|
||||||
|
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = filepath.Join("config", "bootstrap.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
// 配置文件不存在,使用默认配置
|
||||||
|
return GetDefaultBootstrapConfig(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read bootstrap config file %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config BootstrapConfig
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse bootstrap config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据
|
||||||
|
return GetDefaultBootstrapConfig(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultBootstrapConfig 获取默认的预注入配置(示例)
|
||||||
|
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
||||||
|
return &BootstrapConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Users: []UserSeed{
|
||||||
|
{
|
||||||
|
Username: "admin",
|
||||||
|
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=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
181
backend/internal/bootstrap/seeder.go
Normal file
181
backend/internal/bootstrap/seeder.go
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/password"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seeder 预注入管理器
|
||||||
|
type Seeder struct {
|
||||||
|
repos *output.Repositories
|
||||||
|
passwordHasher *password.Hasher
|
||||||
|
config *BootstrapConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeeder 创建预注入管理器
|
||||||
|
func NewSeeder(repos *output.Repositories, passwordHasher *password.Hasher, config *BootstrapConfig) *Seeder {
|
||||||
|
return &Seeder{
|
||||||
|
repos: repos,
|
||||||
|
passwordHasher: passwordHasher,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedAll 执行所有预注入
|
||||||
|
func (s *Seeder) SeedAll(ctx context.Context) error {
|
||||||
|
if !s.config.Enabled {
|
||||||
|
log.Println(" ℹ️ Bootstrap seeding is disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println(" 🌱 Starting bootstrap seeding...")
|
||||||
|
|
||||||
|
// 1. 注入用户
|
||||||
|
if err := s.seedUsers(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to seed users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 注入 Registries
|
||||||
|
if err := s.seedRegistries(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to seed registries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 注入 Clusters
|
||||||
|
if err := s.seedClusters(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to seed clusters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println(" ✅ Bootstrap seeding completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedUsers 注入用户
|
||||||
|
func (s *Seeder) seedUsers(ctx context.Context) error {
|
||||||
|
if len(s.config.Users) == 0 {
|
||||||
|
log.Println(" ↳ No users to seed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ↳ Seeding %d user(s)...", len(s.config.Users))
|
||||||
|
|
||||||
|
for _, userSeed := range s.config.Users {
|
||||||
|
// 检查用户是否已存在
|
||||||
|
existingUser, _ := s.repos.UserRepo.GetByUsername(ctx, userSeed.Username)
|
||||||
|
if existingUser != nil {
|
||||||
|
log.Printf(" ⊙ User '%s' already exists, skipping", userSeed.Username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希密码
|
||||||
|
passwordHash, err := s.passwordHasher.Hash(userSeed.Password)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf(" ✗ Failed to hash password for user '%s': %v", userSeed.Username, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
|
||||||
|
user.ID = uuid.New().String()
|
||||||
|
|
||||||
|
if err := s.repos.UserRepo.Create(ctx, user); err != nil {
|
||||||
|
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ✓ User '%s' created", userSeed.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRegistries 注入 Registries
|
||||||
|
func (s *Seeder) seedRegistries(ctx context.Context) error {
|
||||||
|
if len(s.config.Registries) == 0 {
|
||||||
|
log.Println(" ↳ No registries to seed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
|
||||||
|
|
||||||
|
for _, registrySeed := range s.config.Registries {
|
||||||
|
// 检查 Registry 是否已存在
|
||||||
|
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
|
||||||
|
if existingRegistry != nil {
|
||||||
|
log.Printf(" ⊙ Registry '%s' already exists, skipping", registrySeed.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Registry
|
||||||
|
registry := &entity.Registry{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Name: registrySeed.Name,
|
||||||
|
URL: registrySeed.URL,
|
||||||
|
Description: registrySeed.Description,
|
||||||
|
Username: registrySeed.Username,
|
||||||
|
Password: registrySeed.Password,
|
||||||
|
Insecure: registrySeed.Insecure,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repos.RegistryRepo.Create(ctx, registry); err != nil {
|
||||||
|
log.Printf(" ✗ Failed to create registry '%s': %v", registrySeed.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ✓ Registry '%s' created (credentials encrypted)", registrySeed.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedClusters 注入 Clusters
|
||||||
|
func (s *Seeder) seedClusters(ctx context.Context) error {
|
||||||
|
if len(s.config.Clusters) == 0 {
|
||||||
|
log.Println(" ↳ No clusters to seed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
|
||||||
|
|
||||||
|
for _, clusterSeed := range s.config.Clusters {
|
||||||
|
// 检查 Cluster 是否已存在
|
||||||
|
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
|
||||||
|
if existingCluster != nil {
|
||||||
|
log.Printf(" ⊙ Cluster '%s' already exists, skipping", clusterSeed.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Cluster
|
||||||
|
cluster := &entity.Cluster{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Name: clusterSeed.Name,
|
||||||
|
Host: clusterSeed.Host,
|
||||||
|
Description: clusterSeed.Description,
|
||||||
|
CAData: clusterSeed.CAData,
|
||||||
|
CertData: clusterSeed.CertData,
|
||||||
|
KeyData: clusterSeed.KeyData,
|
||||||
|
Token: clusterSeed.Token,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repos.ClusterRepo.Create(ctx, cluster); err != nil {
|
||||||
|
log.Printf(" ✗ Failed to create cluster '%s': %v", clusterSeed.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" ✓ Cluster '%s' created (credentials encrypted)", clusterSeed.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
171
backend/internal/domain/entity/artifact.go
Normal file
171
backend/internal/domain/entity/artifact.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactType Artifact 类型
|
||||||
|
type ArtifactType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactTypeChart ArtifactType = "chart" // Helm Chart
|
||||||
|
ArtifactTypeImage ArtifactType = "image" // Docker/OCI Image
|
||||||
|
ArtifactTypeOther ArtifactType = "other" // Other types
|
||||||
|
)
|
||||||
|
|
||||||
|
// Artifact OCI Artifact 领域实体
|
||||||
|
type Artifact struct {
|
||||||
|
RegistryID string
|
||||||
|
Repository string
|
||||||
|
Tag string
|
||||||
|
Digest string
|
||||||
|
Type ArtifactType
|
||||||
|
Size int64
|
||||||
|
MediaType string
|
||||||
|
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||||
|
Annotations map[string]string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository 仓库信息
|
||||||
|
type Repository struct {
|
||||||
|
RegistryID string
|
||||||
|
Name string
|
||||||
|
TagCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifact 创建新 Artifact
|
||||||
|
func NewArtifact(registryID, repository, tag, digest string) *Artifact {
|
||||||
|
return &Artifact{
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: repository,
|
||||||
|
Tag: tag,
|
||||||
|
Digest: digest,
|
||||||
|
Annotations: make(map[string]string),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
||||||
|
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
||||||
|
func (a *Artifact) SetType(mediaType string) {
|
||||||
|
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||||
|
|
||||||
|
containsAny := func(target string, keywords ...string) bool {
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
if keyword != "" && strings.Contains(target, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case lowerMediaType == "":
|
||||||
|
a.Type = ArtifactTypeOther
|
||||||
|
case containsAny(lowerMediaType,
|
||||||
|
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||||
|
):
|
||||||
|
a.Type = ArtifactTypeChart
|
||||||
|
case containsAny(lowerMediaType,
|
||||||
|
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||||
|
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||||
|
):
|
||||||
|
a.Type = ArtifactTypeImage
|
||||||
|
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||||
|
a.Type = ArtifactTypeImage
|
||||||
|
default:
|
||||||
|
a.Type = ArtifactTypeOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
||||||
|
// 优先级:
|
||||||
|
// 1. ConfigType (config.mediaType) - 最准确
|
||||||
|
// 2. Annotations - 可能包含类型标注
|
||||||
|
// 3. Repository 名称 - charts/ 前缀暗示
|
||||||
|
// 4. MediaType - 兜底判断
|
||||||
|
func (a *Artifact) DetermineType() {
|
||||||
|
containsAny := func(target string, keywords ...string) bool {
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
if keyword != "" && strings.Contains(target, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||||
|
if a.ConfigType != "" {
|
||||||
|
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||||
|
|
||||||
|
// Helm Chart 的 config.mediaType
|
||||||
|
if containsAny(lowerConfigType,
|
||||||
|
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||||
|
) {
|
||||||
|
a.Type = ArtifactTypeChart
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker/OCI Image 的 config.mediaType
|
||||||
|
if containsAny(lowerConfigType,
|
||||||
|
"docker.container.image", "oci.image.config",
|
||||||
|
) {
|
||||||
|
a.Type = ArtifactTypeImage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查 Annotations
|
||||||
|
for key, value := range a.Annotations {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
lowerValue := strings.ToLower(value)
|
||||||
|
|
||||||
|
if containsAny(lowerKey, "helm", "chart") ||
|
||||||
|
containsAny(lowerValue, "helm", "chart") {
|
||||||
|
a.Type = ArtifactTypeChart
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查 Repository 名称(辅助判断)
|
||||||
|
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||||
|
// charts/ 开头的仓库很可能是 Helm Chart
|
||||||
|
// 但需要结合 MediaType 进一步确认
|
||||||
|
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||||
|
|
||||||
|
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||||
|
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||||
|
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||||
|
a.Type = ArtifactTypeChart
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||||
|
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case lowerMediaType == "":
|
||||||
|
a.Type = ArtifactTypeOther
|
||||||
|
case containsAny(lowerMediaType,
|
||||||
|
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||||
|
):
|
||||||
|
a.Type = ArtifactTypeChart
|
||||||
|
case containsAny(lowerMediaType,
|
||||||
|
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||||
|
):
|
||||||
|
a.Type = ArtifactTypeImage
|
||||||
|
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||||
|
a.Type = ArtifactTypeImage
|
||||||
|
default:
|
||||||
|
a.Type = ArtifactTypeOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsChart 判断是否为 Helm Chart
|
||||||
|
func (a *Artifact) IsChart() bool {
|
||||||
|
return a.Type == ArtifactTypeChart
|
||||||
|
}
|
||||||
|
|
||||||
103
backend/internal/domain/entity/cluster.go
Normal file
103
backend/internal/domain/entity/cluster.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cluster Kubernetes 集群领域实体
|
||||||
|
type Cluster struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Host string // Kubernetes API Server URL
|
||||||
|
CAData string // Base64 encoded CA certificate
|
||||||
|
CertData string // Base64 encoded client certificate
|
||||||
|
KeyData string // Base64 encoded client key
|
||||||
|
Token string // Bearer token (alternative to cert auth)
|
||||||
|
Description string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCluster 创建新集群
|
||||||
|
func NewCluster(name, host string) *Cluster {
|
||||||
|
now := time.Now()
|
||||||
|
return &Cluster{
|
||||||
|
Name: name,
|
||||||
|
Host: host,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新集群信息
|
||||||
|
func (c *Cluster) Update(name, host, description string) {
|
||||||
|
if name != "" {
|
||||||
|
c.Name = name
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
c.Host = host
|
||||||
|
}
|
||||||
|
c.Description = description
|
||||||
|
c.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCertAuth 设置证书认证
|
||||||
|
func (c *Cluster) SetCertAuth(caData, certData, keyData string) {
|
||||||
|
c.CAData = caData
|
||||||
|
c.CertData = certData
|
||||||
|
c.KeyData = keyData
|
||||||
|
c.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTokenAuth 设置 Token 认证
|
||||||
|
func (c *Cluster) SetTokenAuth(token string) {
|
||||||
|
c.Token = token
|
||||||
|
c.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证集群配置
|
||||||
|
func (c *Cluster) Validate() error {
|
||||||
|
if c.Name == "" {
|
||||||
|
return ErrInvalidClusterName
|
||||||
|
}
|
||||||
|
if c.Host == "" {
|
||||||
|
return ErrInvalidClusterHost
|
||||||
|
}
|
||||||
|
// 必须有认证方式:证书或 Token
|
||||||
|
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
||||||
|
return ErrInvalidClusterAuth
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKubeConfig 生成 kubeconfig 内容
|
||||||
|
func (c *Cluster) GetKubeConfig() string {
|
||||||
|
// 如果 CAData 已经包含完整的 kubeconfig,直接返回
|
||||||
|
if len(c.CAData) > 100 && (c.CAData[:11] == "apiVersion:" || c.CAData[:5] == "kind:") {
|
||||||
|
return c.CAData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则从证书数据生成 kubeconfig
|
||||||
|
kubeconfig := `apiVersion: v1
|
||||||
|
kind: Config
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
certificate-authority-data: ` + c.CAData + `
|
||||||
|
server: ` + c.Host + `
|
||||||
|
name: ` + c.Name + `
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: ` + c.Name + `
|
||||||
|
user: ` + c.Name + `
|
||||||
|
name: ` + c.Name + `
|
||||||
|
current-context: ` + c.Name + `
|
||||||
|
users:
|
||||||
|
- name: ` + c.Name + `
|
||||||
|
user:
|
||||||
|
client-certificate-data: ` + c.CertData + `
|
||||||
|
client-key-data: ` + c.KeyData + `
|
||||||
|
`
|
||||||
|
|
||||||
|
return kubeconfig
|
||||||
|
}
|
||||||
|
|
||||||
40
backend/internal/domain/entity/errors.go
Normal file
40
backend/internal/domain/entity/errors.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// 领域错误定义
|
||||||
|
var (
|
||||||
|
// User errors
|
||||||
|
ErrInvalidUsername = errors.New("invalid username")
|
||||||
|
ErrInvalidPassword = errors.New("invalid password")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrUserExists = errors.New("user already exists")
|
||||||
|
ErrTokenRevoked = errors.New("token has been revoked")
|
||||||
|
|
||||||
|
// Cluster errors
|
||||||
|
ErrInvalidClusterName = errors.New("invalid cluster name")
|
||||||
|
ErrInvalidClusterHost = errors.New("invalid cluster host")
|
||||||
|
ErrInvalidClusterAuth = errors.New("invalid cluster authentication config")
|
||||||
|
ErrClusterNotFound = errors.New("cluster not found")
|
||||||
|
ErrClusterExists = errors.New("cluster already exists")
|
||||||
|
|
||||||
|
// Registry errors
|
||||||
|
ErrInvalidRegistryName = errors.New("invalid registry name")
|
||||||
|
ErrInvalidRegistryURL = errors.New("invalid registry URL")
|
||||||
|
ErrRegistryNotFound = errors.New("registry not found")
|
||||||
|
ErrRegistryExists = errors.New("registry already exists")
|
||||||
|
|
||||||
|
// Instance errors
|
||||||
|
ErrInvalidClusterID = errors.New("invalid cluster ID")
|
||||||
|
ErrInvalidInstanceName = errors.New("invalid instance name")
|
||||||
|
ErrInvalidNamespace = errors.New("invalid namespace")
|
||||||
|
ErrInvalidChart = errors.New("invalid chart name")
|
||||||
|
ErrInvalidVersion = errors.New("invalid version")
|
||||||
|
ErrInstanceNotFound = errors.New("instance not found")
|
||||||
|
ErrInstanceExists = errors.New("instance already exists")
|
||||||
|
|
||||||
|
// Artifact errors
|
||||||
|
ErrArtifactNotFound = errors.New("artifact not found")
|
||||||
|
ErrRepositoryNotFound = errors.New("repository not found")
|
||||||
|
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
||||||
|
)
|
||||||
185
backend/internal/domain/entity/instance.go
Normal file
185
backend/internal/domain/entity/instance.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceStatus 实例状态
|
||||||
|
type InstanceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusDeployed InstanceStatus = "deployed"
|
||||||
|
StatusUninstalled InstanceStatus = "uninstalled"
|
||||||
|
StatusSuperseded InstanceStatus = "superseded"
|
||||||
|
StatusFailed InstanceStatus = "failed"
|
||||||
|
StatusPending InstanceStatus = "pending-install"
|
||||||
|
StatusUpgrading InstanceStatus = "pending-upgrade"
|
||||||
|
StatusRollingBack InstanceStatus = "pending-rollback"
|
||||||
|
StatusTerminating InstanceStatus = "pending-delete"
|
||||||
|
StatusUnknown InstanceStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceOperation 实例操作类型
|
||||||
|
type InstanceOperation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationNone InstanceOperation = ""
|
||||||
|
OperationInstall InstanceOperation = "install"
|
||||||
|
OperationUpgrade InstanceOperation = "upgrade"
|
||||||
|
OperationRollback InstanceOperation = "rollback"
|
||||||
|
OperationDelete InstanceOperation = "delete"
|
||||||
|
OperationSync InstanceOperation = "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance Helm 应用实例领域实体
|
||||||
|
type Instance struct {
|
||||||
|
ID string
|
||||||
|
ClusterID string
|
||||||
|
Name string // Helm Release Name
|
||||||
|
Namespace string
|
||||||
|
RegistryID string
|
||||||
|
Repository string // OCI Repository (e.g., charts/app)
|
||||||
|
Chart string // Chart Name
|
||||||
|
Version string // Chart Version
|
||||||
|
Description string
|
||||||
|
Values map[string]interface{} // Helm Values (JSON)
|
||||||
|
ValuesYAML string // Helm Values (YAML format)
|
||||||
|
Status InstanceStatus
|
||||||
|
StatusReason string
|
||||||
|
LastOperation InstanceOperation
|
||||||
|
LastError string
|
||||||
|
Revision int // Helm Release Revision
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstance 创建新实例
|
||||||
|
func NewInstance(clusterID, name, namespace, registryID, repository, chart, version string) *Instance {
|
||||||
|
now := time.Now()
|
||||||
|
return &Instance{
|
||||||
|
ClusterID: clusterID,
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
RegistryID: registryID,
|
||||||
|
Repository: repository,
|
||||||
|
Chart: chart,
|
||||||
|
Version: version,
|
||||||
|
Status: StatusPending,
|
||||||
|
StatusReason: "Pending install",
|
||||||
|
LastOperation: OperationInstall,
|
||||||
|
Revision: 1,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValues 设置 Helm Values
|
||||||
|
func (i *Instance) SetValues(values map[string]interface{}) {
|
||||||
|
i.Values = values
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValuesYAML 设置 YAML 格式的 Values
|
||||||
|
func (i *Instance) SetValuesYAML(yaml string) {
|
||||||
|
i.ValuesYAML = yaml
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 更新实例状态
|
||||||
|
func (i *Instance) UpdateStatus(status InstanceStatus, revision int) {
|
||||||
|
i.Status = status
|
||||||
|
i.Revision = revision
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginOperation 标记开始执行某个操作
|
||||||
|
func (i *Instance) BeginOperation(op InstanceOperation, reason string) {
|
||||||
|
i.LastOperation = op
|
||||||
|
if pendingStatus := pendingStatusForOperation(op); pendingStatus != "" {
|
||||||
|
i.Status = pendingStatus
|
||||||
|
}
|
||||||
|
if reason != "" {
|
||||||
|
i.StatusReason = reason
|
||||||
|
}
|
||||||
|
i.LastError = ""
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSuccess 标记操作成功
|
||||||
|
func (i *Instance) MarkSuccess(status InstanceStatus, revision int, reason string) {
|
||||||
|
if status != "" {
|
||||||
|
i.Status = status
|
||||||
|
}
|
||||||
|
if revision > 0 {
|
||||||
|
i.Revision = revision
|
||||||
|
}
|
||||||
|
i.StatusReason = reason
|
||||||
|
i.LastError = ""
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailure 标记操作失败
|
||||||
|
func (i *Instance) MarkFailure(reason string, err error) {
|
||||||
|
i.Status = StatusFailed
|
||||||
|
if reason != "" {
|
||||||
|
i.StatusReason = reason
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
i.LastError = err.Error()
|
||||||
|
}
|
||||||
|
i.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingStatusForOperation(op InstanceOperation) InstanceStatus {
|
||||||
|
switch op {
|
||||||
|
case OperationInstall:
|
||||||
|
return StatusPending
|
||||||
|
case OperationUpgrade:
|
||||||
|
return StatusUpgrading
|
||||||
|
case OperationRollback:
|
||||||
|
return StatusRollingBack
|
||||||
|
case OperationDelete:
|
||||||
|
return StatusTerminating
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade 升级实例
|
||||||
|
func (i *Instance) Upgrade(version string, values map[string]interface{}) {
|
||||||
|
i.Version = version
|
||||||
|
if values != nil {
|
||||||
|
i.Values = values
|
||||||
|
}
|
||||||
|
i.BeginOperation(OperationUpgrade, "Pending upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证实例配置
|
||||||
|
func (i *Instance) Validate() error {
|
||||||
|
if i.ClusterID == "" {
|
||||||
|
return ErrInvalidClusterID
|
||||||
|
}
|
||||||
|
if i.Name == "" {
|
||||||
|
return ErrInvalidInstanceName
|
||||||
|
}
|
||||||
|
if i.Namespace == "" {
|
||||||
|
return ErrInvalidNamespace
|
||||||
|
}
|
||||||
|
if i.Chart == "" {
|
||||||
|
return ErrInvalidChart
|
||||||
|
}
|
||||||
|
if i.Version == "" {
|
||||||
|
return ErrInvalidVersion
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseHistory Helm Release 历史记录
|
||||||
|
type ReleaseHistory struct {
|
||||||
|
Revision int
|
||||||
|
Updated time.Time
|
||||||
|
Status InstanceStatus
|
||||||
|
Chart string
|
||||||
|
AppVersion string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
43
backend/internal/domain/entity/instance_entry.go
Normal file
43
backend/internal/domain/entity/instance_entry.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
// InstanceEntry 描述实例关联的访问入口(Service、Ingress 等)
|
||||||
|
type InstanceEntry struct {
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
Namespace string
|
||||||
|
Type string
|
||||||
|
ClusterIP string
|
||||||
|
ExternalIPs []string
|
||||||
|
LoadBalancerIngress []string
|
||||||
|
Ports []InstanceEntryPort
|
||||||
|
Hosts []InstanceEntryHost
|
||||||
|
TLS []InstanceEntryTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryPort Service 端口信息
|
||||||
|
type InstanceEntryPort struct {
|
||||||
|
Name string
|
||||||
|
Protocol string
|
||||||
|
Port int32
|
||||||
|
TargetPort string
|
||||||
|
NodePort int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryHost Ingress Host 配置
|
||||||
|
type InstanceEntryHost struct {
|
||||||
|
Host string
|
||||||
|
Paths []InstanceEntryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryPath Ingress path 详情
|
||||||
|
type InstanceEntryPath struct {
|
||||||
|
Path string
|
||||||
|
ServiceName string
|
||||||
|
ServicePort string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceEntryTLS Ingress TLS 配置
|
||||||
|
type InstanceEntryTLS struct {
|
||||||
|
Hosts []string
|
||||||
|
SecretName string
|
||||||
|
}
|
||||||
83
backend/internal/domain/entity/metrics.go
Normal file
83
backend/internal/domain/entity/metrics.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ClusterMetrics 集群监控指标
|
||||||
|
type ClusterMetrics struct {
|
||||||
|
ClusterID string `json:"cluster_id"`
|
||||||
|
ClusterName string `json:"cluster_name"`
|
||||||
|
Status string `json:"status"` // healthy, warning, error, unknown
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
NodeCount int `json:"node_count"`
|
||||||
|
PodCount int `json:"pod_count"`
|
||||||
|
LastCheck time.Time `json:"last_check"`
|
||||||
|
|
||||||
|
// 集群级别资源汇总
|
||||||
|
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||||
|
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||||
|
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||||
|
|
||||||
|
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||||
|
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||||
|
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||||
|
|
||||||
|
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||||
|
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||||
|
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||||
|
|
||||||
|
// 单机资源最大值
|
||||||
|
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||||
|
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||||
|
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||||
|
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||||
|
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||||
|
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||||
|
|
||||||
|
// 节点列表(简化信息)
|
||||||
|
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeMetrics 节点监控指标
|
||||||
|
type NodeMetrics struct {
|
||||||
|
NodeName string `json:"node_name"`
|
||||||
|
Status string `json:"status"` // Ready, NotReady
|
||||||
|
Role string `json:"role"` // control-plane, worker
|
||||||
|
Age string `json:"age"`
|
||||||
|
PodCount int `json:"pod_count"`
|
||||||
|
|
||||||
|
// CPU 资源
|
||||||
|
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||||
|
CPUAllocatable string `json:"cpu_allocatable"`
|
||||||
|
CPUUsage string `json:"cpu_usage"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
|
||||||
|
// 内存资源
|
||||||
|
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||||
|
MemoryAllocatable string `json:"memory_allocatable"`
|
||||||
|
MemoryUsage string `json:"memory_usage"`
|
||||||
|
MemoryPercent float64 `json:"memory_percent"`
|
||||||
|
|
||||||
|
// GPU 资源(如果有)
|
||||||
|
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
||||||
|
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
||||||
|
GPUPercent float64 `json:"gpu_percent"`
|
||||||
|
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
||||||
|
|
||||||
|
// 其他信息
|
||||||
|
OSImage string `json:"os_image,omitempty"`
|
||||||
|
KernelVersion string `json:"kernel_version,omitempty"`
|
||||||
|
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||||
|
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitoringSummary 监控汇总
|
||||||
|
type MonitoringSummary struct {
|
||||||
|
TotalClusters int `json:"total_clusters"`
|
||||||
|
HealthyClusters int `json:"healthy_clusters"`
|
||||||
|
WarningClusters int `json:"warning_clusters"`
|
||||||
|
ErrorClusters int `json:"error_clusters"`
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
TotalPods int `json:"total_pods"`
|
||||||
|
LastUpdate time.Time `json:"last_update"`
|
||||||
|
}
|
||||||
|
|
||||||
60
backend/internal/domain/entity/registry.go
Normal file
60
backend/internal/domain/entity/registry.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry OCI Registry 领域实体
|
||||||
|
type Registry struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Description string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Insecure bool // 是否跳过 TLS 验证
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry 创建新 Registry
|
||||||
|
func NewRegistry(name, url string) *Registry {
|
||||||
|
now := time.Now()
|
||||||
|
return &Registry{
|
||||||
|
Name: name,
|
||||||
|
URL: url,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新 Registry 信息
|
||||||
|
func (r *Registry) Update(name, url, description string) {
|
||||||
|
if name != "" {
|
||||||
|
r.Name = name
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
r.URL = url
|
||||||
|
}
|
||||||
|
r.Description = description
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCredentials 设置认证凭据
|
||||||
|
func (r *Registry) SetCredentials(username, password string) {
|
||||||
|
r.Username = username
|
||||||
|
r.Password = password
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证 Registry 配置
|
||||||
|
func (r *Registry) Validate() error {
|
||||||
|
if r.Name == "" {
|
||||||
|
return ErrInvalidRegistryName
|
||||||
|
}
|
||||||
|
if r.URL == "" {
|
||||||
|
return ErrInvalidRegistryURL
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
54
backend/internal/domain/entity/user.go
Normal file
54
backend/internal/domain/entity/user.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 用户领域实体
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Email string
|
||||||
|
RevokedAfter time.Time // 全局 Token 撤销时间
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUser 创建新用户
|
||||||
|
func NewUser(username, passwordHash, email string) *User {
|
||||||
|
now := time.Now()
|
||||||
|
return &User{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Email: email,
|
||||||
|
RevokedAfter: time.Unix(0, 0), // 初始值:1970-01-01
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword 更新密码(会触发全局登出)
|
||||||
|
func (u *User) UpdatePassword(newPasswordHash string) {
|
||||||
|
u.PasswordHash = newPasswordHash
|
||||||
|
u.RevokedAfter = time.Now() // 撤销所有旧 Token
|
||||||
|
u.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllTokens 撤销所有 Token(强制全局登出)
|
||||||
|
func (u *User) RevokeAllTokens() {
|
||||||
|
u.RevokedAfter = time.Now()
|
||||||
|
u.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证用户数据
|
||||||
|
func (u *User) Validate() error {
|
||||||
|
if u.Username == "" {
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
if u.PasswordHash == "" {
|
||||||
|
return ErrInvalidPassword
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
28
backend/internal/domain/repository/cluster_repository.go
Normal file
28
backend/internal/domain/repository/cluster_repository.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterRepository 集群仓储接口(Output Port)
|
||||||
|
type ClusterRepository interface {
|
||||||
|
// Create 创建集群
|
||||||
|
Create(ctx context.Context, cluster *entity.Cluster) error
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取集群
|
||||||
|
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
|
||||||
|
|
||||||
|
// GetByName 根据名称获取集群
|
||||||
|
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
|
||||||
|
|
||||||
|
// Update 更新集群
|
||||||
|
Update(ctx context.Context, cluster *entity.Cluster) error
|
||||||
|
|
||||||
|
// Delete 删除集群
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// List 列出所有集群
|
||||||
|
List(ctx context.Context) ([]*entity.Cluster, error)
|
||||||
|
}
|
||||||
|
|
||||||
34
backend/internal/domain/repository/helm_client.go
Normal file
34
backend/internal/domain/repository/helm_client.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelmClient Helm 客户端接口(Output Port)
|
||||||
|
type HelmClient interface {
|
||||||
|
// Install 安装 Helm Chart
|
||||||
|
Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||||
|
|
||||||
|
// Upgrade 升级 Helm Release
|
||||||
|
Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||||
|
|
||||||
|
// Uninstall 卸载 Helm Release
|
||||||
|
Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error
|
||||||
|
|
||||||
|
// Rollback 回滚 Helm Release
|
||||||
|
Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error
|
||||||
|
|
||||||
|
// GetStatus 获取 Release 状态
|
||||||
|
GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error)
|
||||||
|
|
||||||
|
// GetHistory 获取 Release 历史
|
||||||
|
GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error)
|
||||||
|
|
||||||
|
// List 列出集群中的所有 Releases
|
||||||
|
List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error)
|
||||||
|
|
||||||
|
// GetValues 获取 Release 的 values
|
||||||
|
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
13
backend/internal/domain/repository/instance_entry_client.go
Normal file
13
backend/internal/domain/repository/instance_entry_client.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceEntryClient 从 Kubernetes 集群中查询实例访问入口的接口
|
||||||
|
type InstanceEntryClient interface {
|
||||||
|
// ListEntries 返回指定实例(Helm Release)相关的 Service/Ingress 等入口信息
|
||||||
|
ListEntries(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) ([]*entity.InstanceEntry, error)
|
||||||
|
}
|
||||||
31
backend/internal/domain/repository/instance_repository.go
Normal file
31
backend/internal/domain/repository/instance_repository.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceRepository 实例仓储接口(Output Port)
|
||||||
|
type InstanceRepository interface {
|
||||||
|
// Create 创建实例
|
||||||
|
Create(ctx context.Context, instance *entity.Instance) error
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取实例
|
||||||
|
GetByID(ctx context.Context, id string) (*entity.Instance, error)
|
||||||
|
|
||||||
|
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||||
|
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
|
||||||
|
|
||||||
|
// Update 更新实例
|
||||||
|
Update(ctx context.Context, instance *entity.Instance) error
|
||||||
|
|
||||||
|
// Delete 删除实例
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// ListByCluster 列出指定集群的所有实例
|
||||||
|
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
|
||||||
|
|
||||||
|
// List 列出所有实例
|
||||||
|
List(ctx context.Context) ([]*entity.Instance, error)
|
||||||
|
}
|
||||||
|
|
||||||
17
backend/internal/domain/repository/metrics_client.go
Normal file
17
backend/internal/domain/repository/metrics_client.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricsClient 定义获取集群监控指标的接口
|
||||||
|
type MetricsClient interface {
|
||||||
|
// GetClusterMetrics 获取集群的监控指标
|
||||||
|
GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error)
|
||||||
|
|
||||||
|
// GetNodeMetrics 获取集群的节点指标
|
||||||
|
GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error)
|
||||||
|
}
|
||||||
|
|
||||||
32
backend/internal/domain/repository/oci_client.go
Normal file
32
backend/internal/domain/repository/oci_client.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OCIClient OCI Registry 客户端接口(Output Port)
|
||||||
|
type OCIClient interface {
|
||||||
|
// ListRepositories 列出 Registry 中的所有 repositories
|
||||||
|
ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error)
|
||||||
|
|
||||||
|
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||||
|
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||||
|
ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error)
|
||||||
|
|
||||||
|
// GetArtifact 获取指定 artifact 的详细信息
|
||||||
|
GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error)
|
||||||
|
|
||||||
|
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||||
|
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||||
|
|
||||||
|
// PullArtifact 下载 artifact 到本地
|
||||||
|
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error
|
||||||
|
|
||||||
|
// PushArtifact 推送 artifact 到 Registry
|
||||||
|
PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error
|
||||||
|
|
||||||
|
// CheckHealth 检查 Registry 健康状态
|
||||||
|
CheckHealth(ctx context.Context, registry *entity.Registry) error
|
||||||
|
}
|
||||||
|
|
||||||
28
backend/internal/domain/repository/registry_repository.go
Normal file
28
backend/internal/domain/repository/registry_repository.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryRepository Registry 仓储接口(Output Port)
|
||||||
|
type RegistryRepository interface {
|
||||||
|
// Create 创建 Registry
|
||||||
|
Create(ctx context.Context, registry *entity.Registry) error
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取 Registry
|
||||||
|
GetByID(ctx context.Context, id string) (*entity.Registry, error)
|
||||||
|
|
||||||
|
// GetByName 根据名称获取 Registry
|
||||||
|
GetByName(ctx context.Context, name string) (*entity.Registry, error)
|
||||||
|
|
||||||
|
// Update 更新 Registry
|
||||||
|
Update(ctx context.Context, registry *entity.Registry) error
|
||||||
|
|
||||||
|
// Delete 删除 Registry
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// List 列出所有 Registries
|
||||||
|
List(ctx context.Context) ([]*entity.Registry, error)
|
||||||
|
}
|
||||||
|
|
||||||
28
backend/internal/domain/repository/user_repository.go
Normal file
28
backend/internal/domain/repository/user_repository.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepository 用户仓储接口(Output Port)
|
||||||
|
type UserRepository interface {
|
||||||
|
// Create 创建用户
|
||||||
|
Create(ctx context.Context, user *entity.User) error
|
||||||
|
|
||||||
|
// GetByID 根据 ID 获取用户
|
||||||
|
GetByID(ctx context.Context, id string) (*entity.User, error)
|
||||||
|
|
||||||
|
// GetByUsername 根据用户名获取用户
|
||||||
|
GetByUsername(ctx context.Context, username string) (*entity.User, error)
|
||||||
|
|
||||||
|
// Update 更新用户
|
||||||
|
Update(ctx context.Context, user *entity.User) error
|
||||||
|
|
||||||
|
// Delete 删除用户
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// List 列出所有用户
|
||||||
|
List(ctx context.Context) ([]*entity.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
80
backend/internal/domain/service/artifact_service.go
Normal file
80
backend/internal/domain/service/artifact_service.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactService Artifact 浏览领域服务
|
||||||
|
type ArtifactService struct {
|
||||||
|
registryRepo repository.RegistryRepository
|
||||||
|
ociClient repository.OCIClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifactService 创建 Artifact 服务
|
||||||
|
func NewArtifactService(
|
||||||
|
registryRepo repository.RegistryRepository,
|
||||||
|
ociClient repository.OCIClient,
|
||||||
|
) *ArtifactService {
|
||||||
|
return &ArtifactService{
|
||||||
|
registryRepo: registryRepo,
|
||||||
|
ociClient: ociClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistry 获取 Registry 信息
|
||||||
|
func (s *ArtifactService) GetRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||||
|
return s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepositories 列出 Registry 中的所有 repositories
|
||||||
|
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID string) ([]string, error) {
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.ListRepositories(ctx, registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||||
|
func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.ListArtifacts(ctx, registry, repository, mediaTypeFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifact 获取 artifact 详情
|
||||||
|
func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repository, reference string) (*entity.Artifact, error) {
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.GetArtifact(ctx, registry, repository, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||||
|
func (s *ArtifactService) GetValuesSchema(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.GetValuesSchema(ctx, registry, repository, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullArtifact 下载 artifact
|
||||||
|
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.PullArtifact(ctx, registry, repository, reference, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
165
backend/internal/domain/service/auth_service.go
Normal file
165
backend/internal/domain/service/auth_service.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService 认证领域服务
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo repository.UserRepository
|
||||||
|
passwordHasher PasswordHasher
|
||||||
|
tokenGenerator TokenGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordHasher 密码哈希接口
|
||||||
|
type PasswordHasher interface {
|
||||||
|
Hash(password string) (string, error)
|
||||||
|
Verify(password, hash string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenGenerator Token 生成器接口
|
||||||
|
type TokenGenerator interface {
|
||||||
|
Generate(userID, username string) (accessToken, refreshToken string, err error)
|
||||||
|
Verify(token string) (userID, username string, err error)
|
||||||
|
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||||||
|
Refresh(refreshToken string) (newAccessToken string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService 创建认证服务
|
||||||
|
func NewAuthService(
|
||||||
|
userRepo repository.UserRepository,
|
||||||
|
passwordHasher PasswordHasher,
|
||||||
|
tokenGenerator TokenGenerator,
|
||||||
|
) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
passwordHasher: passwordHasher,
|
||||||
|
tokenGenerator: tokenGenerator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册新用户(仅需用户名和密码,邮箱将自动补全)
|
||||||
|
func (s *AuthService) Register(ctx context.Context, username, password string) (*entity.User, error) {
|
||||||
|
// 检查用户是否已存在
|
||||||
|
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, entity.ErrUserExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希密码
|
||||||
|
passwordHash, err := s.passwordHasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认生成占位邮箱,避免数据库约束失败
|
||||||
|
email := username + "@local.ocdp"
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
user := entity.NewUser(username, passwordHash, email)
|
||||||
|
user.ID = uuid.New().String()
|
||||||
|
|
||||||
|
if err := user.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录
|
||||||
|
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, err error) {
|
||||||
|
// 查找用户
|
||||||
|
user, err := s.userRepo.GetByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||||||
|
return "", "", entity.ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Token
|
||||||
|
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken 刷新 Token
|
||||||
|
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
|
||||||
|
return s.tokenGenerator.Refresh(refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID 根据 ID 获取用户
|
||||||
|
func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
|
return s.userRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||||||
|
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
|
||||||
|
// 1. JWT 自验证
|
||||||
|
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查用户级别的撤销时间
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||||||
|
if issuedAt < user.RevokedAfter.Unix() {
|
||||||
|
return "", "", entity.ErrTokenRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改密码(会触发全局登出)
|
||||||
|
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
||||||
|
// 1. 获取用户
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证旧密码
|
||||||
|
if err := s.passwordHasher.Verify(oldPassword, user.PasswordHash); err != nil {
|
||||||
|
return entity.ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 哈希新密码
|
||||||
|
newPasswordHash, err := s.passwordHasher.Hash(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新密码(会自动触发 revoked_after 更新)
|
||||||
|
user.UpdatePassword(newPasswordHash)
|
||||||
|
|
||||||
|
// 5. 保存到数据库
|
||||||
|
return s.userRepo.Update(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceLogoutAll 强制全局登出(管理员操作)
|
||||||
|
func (s *AuthService) ForceLogoutAll(ctx context.Context, userID string) error {
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
user.RevokeAllTokens()
|
||||||
|
return s.userRepo.Update(ctx, user)
|
||||||
|
}
|
||||||
77
backend/internal/domain/service/cluster_service.go
Normal file
77
backend/internal/domain/service/cluster_service.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterService 集群管理领域服务
|
||||||
|
type ClusterService struct {
|
||||||
|
clusterRepo repository.ClusterRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterService 创建集群服务
|
||||||
|
func NewClusterService(clusterRepo repository.ClusterRepository) *ClusterService {
|
||||||
|
return &ClusterService{
|
||||||
|
clusterRepo: clusterRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCluster 创建新集群
|
||||||
|
func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
// 生成 ID
|
||||||
|
cluster.ID = uuid.New().String()
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if err := cluster.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
existingCluster, _ := s.clusterRepo.GetByName(ctx, cluster.Name)
|
||||||
|
if existingCluster != nil {
|
||||||
|
return entity.ErrClusterExists
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.clusterRepo.Create(ctx, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCluster 获取集群
|
||||||
|
func (s *ClusterService) GetCluster(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
|
return s.clusterRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCluster 更新集群
|
||||||
|
func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
// 检查是否存在
|
||||||
|
_, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if err := cluster.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.clusterRepo.Update(ctx, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCluster 删除集群
|
||||||
|
func (s *ClusterService) DeleteCluster(ctx context.Context, id string) error {
|
||||||
|
// 检查是否存在
|
||||||
|
_, err := s.clusterRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.clusterRepo.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClusters 列出所有集群
|
||||||
|
func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
|
return s.clusterRepo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
456
backend/internal/domain/service/instance_service.go
Normal file
456
backend/internal/domain/service/instance_service.go
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstanceService Helm 实例管理领域服务
|
||||||
|
type InstanceService struct {
|
||||||
|
instanceRepo repository.InstanceRepository
|
||||||
|
clusterRepo repository.ClusterRepository
|
||||||
|
registryRepo repository.RegistryRepository
|
||||||
|
helmClient repository.HelmClient
|
||||||
|
ociClient repository.OCIClient
|
||||||
|
entryClient repository.InstanceEntryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstanceService 创建实例服务
|
||||||
|
func NewInstanceService(
|
||||||
|
instanceRepo repository.InstanceRepository,
|
||||||
|
clusterRepo repository.ClusterRepository,
|
||||||
|
registryRepo repository.RegistryRepository,
|
||||||
|
helmClient repository.HelmClient,
|
||||||
|
ociClient repository.OCIClient,
|
||||||
|
entryClient repository.InstanceEntryClient,
|
||||||
|
) *InstanceService {
|
||||||
|
return &InstanceService{
|
||||||
|
instanceRepo: instanceRepo,
|
||||||
|
clusterRepo: clusterRepo,
|
||||||
|
registryRepo: registryRepo,
|
||||||
|
helmClient: helmClient,
|
||||||
|
ociClient: ociClient,
|
||||||
|
entryClient: entryClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartCacheDir = "/tmp/charts"
|
||||||
|
|
||||||
|
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
||||||
|
filename := fmt.Sprintf("%s-%s.tgz", instance.Chart, instance.Version)
|
||||||
|
return filepath.Join(chartCacheDir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) downloadChart(ctx context.Context, registry *entity.Registry, instance *entity.Instance) error {
|
||||||
|
if err := os.MkdirAll(chartCacheDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure chart cache dir: %w", err)
|
||||||
|
}
|
||||||
|
chartPath := s.chartArchivePath(instance)
|
||||||
|
if err := s.ociClient.PullArtifact(ctx, registry, instance.Repository, instance.Version, chartPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to download chart artifact: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInstance 创建(安装)新实例
|
||||||
|
func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
// 生成 ID
|
||||||
|
instance.ID = uuid.New().String()
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if err := instance.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查集群是否存在
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Registry 是否存在
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查实例是否已存在
|
||||||
|
existingInstance, _ := s.instanceRepo.GetByClusterAndName(ctx, instance.ClusterID, instance.Name)
|
||||||
|
if existingInstance != nil {
|
||||||
|
return entity.ErrInstanceExists
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.BeginOperation(entity.OperationInstall, "Preparing installation")
|
||||||
|
|
||||||
|
// 先写入数据库,记录 pending 状态
|
||||||
|
if err := s.instanceRepo.Create(ctx, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载 chart artifact 供 Helm 使用
|
||||||
|
if err := s.downloadChart(ctx, registry, instance); err != nil {
|
||||||
|
instance.MarkFailure("Failed to download chart", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行 Helm 安装并监控状态
|
||||||
|
go s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance)
|
||||||
|
|
||||||
|
// 立即返回,状态同步由后台任务处理
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstance 获取实例
|
||||||
|
func (s *InstanceService) GetInstance(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
|
return s.instanceRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceStatus 获取实例实时状态
|
||||||
|
func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
|
// 从数据库获取基本信息
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Helm 获取实时状态
|
||||||
|
liveStatus, err := s.helmClient.GetStatus(ctx, cluster, instance.Name, instance.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return instance, err // 返回数据库中的信息,但标记错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并实时状态
|
||||||
|
instance.Status = liveStatus.Status
|
||||||
|
instance.Revision = liveStatus.Revision
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstance 更新(升级)实例
|
||||||
|
func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
// 检查实例是否存在
|
||||||
|
existingInstance, err := s.instanceRepo.GetByID(ctx, instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, existingInstance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Registry 信息
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.BeginOperation(entity.OperationUpgrade, "Pending upgrade")
|
||||||
|
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载所需 Chart
|
||||||
|
if err := s.downloadChart(ctx, registry, instance); err != nil {
|
||||||
|
instance.MarkFailure("Failed to download chart", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行 Helm 升级并监控状态
|
||||||
|
go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, registry, instance)
|
||||||
|
|
||||||
|
// 立即返回,状态同步由后台任务处理
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInstance 删除(卸载)实例
|
||||||
|
func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
||||||
|
// 检查实例是否存在
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.BeginOperation(entity.OperationDelete, "Pending uninstall")
|
||||||
|
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行 Helm 卸载并监控状态
|
||||||
|
go s.executeAndSyncUninstall(context.Background(), instance.ID, cluster, instance.Name, instance.Namespace)
|
||||||
|
|
||||||
|
// 立即返回,状态同步由后台任务处理
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RollbackInstance 回滚实例
|
||||||
|
func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revision int) error {
|
||||||
|
// 检查实例是否存在
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.BeginOperation(entity.OperationRollback, fmt.Sprintf("Rolling back to revision %d", revision))
|
||||||
|
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行 Helm 回滚并监控状态
|
||||||
|
go s.executeAndSyncRollback(context.Background(), instance.ID, cluster, instance.Name, instance.Namespace, revision)
|
||||||
|
|
||||||
|
// 立即返回,状态同步由后台任务处理
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceHistory 获取实例历史
|
||||||
|
func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]*entity.ReleaseHistory, error) {
|
||||||
|
// 检查实例是否存在
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取集群信息
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Helm 获取历史
|
||||||
|
return s.helmClient.GetHistory(ctx, cluster, instance.Name, instance.Namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstancesByCluster 列出集群的所有实例
|
||||||
|
func (s *InstanceService) ListInstancesByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
|
// 检查集群是否存在
|
||||||
|
_, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.instanceRepo.ListByCluster(ctx, clusterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstanceEntries 列出实例关联的入口信息(Service / Ingress)
|
||||||
|
func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, instanceID string) ([]*entity.InstanceEntry, error) {
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if instance.ClusterID != clusterID {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.entryClient == nil {
|
||||||
|
return nil, fmt.Errorf("instance entry client is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.entryClient.ListEntries(ctx, cluster, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeAndSyncInstall 异步执行安装并监控状态
|
||||||
|
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||||
|
// 执行 Helm 安装
|
||||||
|
if err := s.helmClient.Install(ctx, cluster, instance); err != nil {
|
||||||
|
// 更新实例状态为失败
|
||||||
|
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if updateErr == nil && instance != nil {
|
||||||
|
instance.MarkFailure("Helm install failed", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装成功后,同步状态
|
||||||
|
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeAndSyncUpgrade 异步执行升级并监控状态
|
||||||
|
func (s *InstanceService) executeAndSyncUpgrade(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||||
|
// 执行 Helm 升级
|
||||||
|
if err := s.helmClient.Upgrade(ctx, cluster, instance); err != nil {
|
||||||
|
// 更新实例状态为失败
|
||||||
|
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if updateErr == nil && instance != nil {
|
||||||
|
instance.MarkFailure("Helm upgrade failed", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 升级成功后,同步状态
|
||||||
|
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationUpgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeAndSyncRollback 异步执行回滚并监控状态
|
||||||
|
func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, revision int) {
|
||||||
|
// 执行 Helm 回滚
|
||||||
|
if err := s.helmClient.Rollback(ctx, cluster, releaseName, namespace, revision); err != nil {
|
||||||
|
// 更新实例状态为失败
|
||||||
|
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if updateErr == nil && instance != nil {
|
||||||
|
instance.MarkFailure("Helm rollback failed", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回滚成功后,同步状态
|
||||||
|
s.syncInstanceStatus(ctx, instanceID, cluster, releaseName, namespace, entity.OperationRollback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeAndSyncUninstall 异步执行卸载并监控状态
|
||||||
|
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
|
||||||
|
// 执行 Helm 卸载
|
||||||
|
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
|
||||||
|
|
||||||
|
// 获取实例
|
||||||
|
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if getErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 如果错误不是"未找到",则标记为失败
|
||||||
|
if !errors.Is(err, entity.ErrInstanceNotFound) {
|
||||||
|
instance.MarkFailure("Helm uninstall failed", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
} else {
|
||||||
|
// 如果未找到,说明已经卸载,直接删除数据库记录
|
||||||
|
_ = s.instanceRepo.Delete(ctx, instanceID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卸载成功,标记为已卸载
|
||||||
|
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
|
||||||
|
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||||
|
if statusErr != nil {
|
||||||
|
// 无法获取状态,说明已卸载,删除数据库记录
|
||||||
|
_ = s.instanceRepo.Delete(ctx, instanceID)
|
||||||
|
} else {
|
||||||
|
// 仍然可以获取状态,可能还在卸载中,继续等待
|
||||||
|
// 设置状态为 uninstalled,但不删除记录,让用户手动删除或等待自动清理
|
||||||
|
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Uninstall in progress")
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncInstanceStatus 同步实例状态(定期检查 Helm 状态并更新数据库)
|
||||||
|
func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, operation entity.InstanceOperation) {
|
||||||
|
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
||||||
|
interval := 10 * time.Second // 每10秒检查一次
|
||||||
|
|
||||||
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
time.Sleep(interval)
|
||||||
|
|
||||||
|
// 获取数据库中的实例
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
// 实例不存在,停止同步
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Helm 获取实时状态
|
||||||
|
liveStatus, err := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||||
|
if err != nil {
|
||||||
|
// 如果获取状态失败,可能是还在部署中,继续等待
|
||||||
|
if i < maxAttempts-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 最后一次尝试失败,标记为失败
|
||||||
|
instance.MarkFailure("Failed to get status from Helm", err)
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据操作类型和 Helm 状态更新实例状态
|
||||||
|
shouldUpdate := false
|
||||||
|
switch operation {
|
||||||
|
case entity.OperationInstall:
|
||||||
|
// 安装操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||||
|
if liveStatus.Status == entity.StatusDeployed {
|
||||||
|
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance deployed successfully")
|
||||||
|
shouldUpdate = true
|
||||||
|
} else if liveStatus.Status == entity.StatusFailed {
|
||||||
|
instance.MarkFailure("Installation failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||||
|
shouldUpdate = true
|
||||||
|
}
|
||||||
|
case entity.OperationUpgrade:
|
||||||
|
// 升级操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||||
|
if liveStatus.Status == entity.StatusDeployed {
|
||||||
|
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance upgraded successfully")
|
||||||
|
shouldUpdate = true
|
||||||
|
} else if liveStatus.Status == entity.StatusFailed {
|
||||||
|
instance.MarkFailure("Upgrade failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||||
|
shouldUpdate = true
|
||||||
|
}
|
||||||
|
case entity.OperationRollback:
|
||||||
|
// 回滚操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||||
|
if liveStatus.Status == entity.StatusDeployed {
|
||||||
|
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance rolled back successfully")
|
||||||
|
shouldUpdate = true
|
||||||
|
} else if liveStatus.Status == entity.StatusFailed {
|
||||||
|
instance.MarkFailure("Rollback failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||||
|
shouldUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果状态已更新为最终状态,停止同步
|
||||||
|
if shouldUpdate {
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果状态已经是最终状态(deployed 或 failed),停止同步
|
||||||
|
if instance.Status == entity.StatusDeployed || instance.Status == entity.StatusFailed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时,标记为失败
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if err == nil && instance != nil {
|
||||||
|
instance.MarkFailure("Operation timeout", fmt.Errorf("Status sync timeout after %d attempts", maxAttempts))
|
||||||
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/internal/domain/service/instance_service_test.go
Normal file
111
backend/internal/domain/service/instance_service_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
persistencemock "github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
instanceRepo := persistencemock.NewInstanceRepositoryMock()
|
||||||
|
|
||||||
|
instance := &entity.Instance{
|
||||||
|
ID: "inst-1",
|
||||||
|
ClusterID: "cluster-1",
|
||||||
|
Name: "demo",
|
||||||
|
Namespace: "default",
|
||||||
|
}
|
||||||
|
if err := instanceRepo.Create(ctx, instance); err != nil {
|
||||||
|
t.Fatalf("failed to seed instance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster := &entity.Cluster{ID: "cluster-1", Name: "cluster", Host: "https://example.com"}
|
||||||
|
clusterRepo := &stubClusterRepo{cluster: cluster}
|
||||||
|
|
||||||
|
svc := NewInstanceService(
|
||||||
|
instanceRepo,
|
||||||
|
clusterRepo,
|
||||||
|
nil,
|
||||||
|
&stubHelmClient{uninstallErr: entity.ErrInstanceNotFound},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := svc.DeleteInstance(ctx, instance.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteInstance returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := instanceRepo.GetByID(ctx, instance.ID); !errors.Is(err, entity.ErrInstanceNotFound) {
|
||||||
|
t.Fatalf("expected instance removed, got err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubClusterRepo struct {
|
||||||
|
cluster *entity.Cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClusterRepo) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
s.cluster = cluster
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClusterRepo) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
|
if s.cluster != nil && s.cluster.ID == id {
|
||||||
|
return s.cluster, nil
|
||||||
|
}
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubClusterRepo) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubClusterRepo) Update(ctx context.Context, cluster *entity.Cluster) error { return nil }
|
||||||
|
|
||||||
|
func (*stubClusterRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||||
|
|
||||||
|
func (*stubClusterRepo) List(ctx context.Context) ([]*entity.Cluster, error) { return nil, nil }
|
||||||
|
|
||||||
|
type stubHelmClient struct {
|
||||||
|
uninstallErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubHelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||||
|
return s.uninstallErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repository.ClusterRepository = (*stubClusterRepo)(nil)
|
||||||
|
var _ repository.HelmClient = (*stubHelmClient)(nil)
|
||||||
102
backend/internal/domain/service/monitoring_service.go
Normal file
102
backend/internal/domain/service/monitoring_service.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MonitoringService 监控服务
|
||||||
|
type MonitoringService struct {
|
||||||
|
clusterRepo repository.ClusterRepository
|
||||||
|
metricsClient repository.MetricsClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitoringService 创建监控服务
|
||||||
|
func NewMonitoringService(
|
||||||
|
clusterRepo repository.ClusterRepository,
|
||||||
|
metricsClient repository.MetricsClient,
|
||||||
|
) *MonitoringService {
|
||||||
|
return &MonitoringService{
|
||||||
|
clusterRepo: clusterRepo,
|
||||||
|
metricsClient: metricsClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterMonitoring 获取单个集群的监控信息
|
||||||
|
func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||||
|
metrics, err := s.metricsClient.GetClusterMetrics(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cluster metrics: %w", err)
|
||||||
|
}
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClusterMonitoring 获取所有集群的监控信息
|
||||||
|
func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entity.ClusterMetrics, error) {
|
||||||
|
// 获取所有集群
|
||||||
|
clusters, err := s.clusterRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每个集群的监控数据
|
||||||
|
result := make([]*entity.ClusterMetrics, 0, len(clusters))
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
metrics, err := s.metricsClient.GetClusterMetrics(ctx, cluster.ID)
|
||||||
|
if err != nil {
|
||||||
|
// 如果某个集群获取失败,记录错误但继续
|
||||||
|
fmt.Printf("Warning: failed to get metrics for cluster %s: %v\n", cluster.ID, err)
|
||||||
|
// 返回基本信息
|
||||||
|
metrics = &entity.ClusterMetrics{
|
||||||
|
ClusterID: cluster.ID,
|
||||||
|
ClusterName: cluster.Name,
|
||||||
|
Status: "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMonitoringSummary 获取监控汇总信息
|
||||||
|
func (s *MonitoringService) GetMonitoringSummary(ctx context.Context) (*entity.MonitoringSummary, error) {
|
||||||
|
// 获取所有集群监控数据
|
||||||
|
monitoringList, err := s.ListClusterMonitoring(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list monitoring: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计汇总
|
||||||
|
summary := &entity.MonitoringSummary{
|
||||||
|
TotalClusters: len(monitoringList),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range monitoringList {
|
||||||
|
switch m.Status {
|
||||||
|
case "healthy":
|
||||||
|
summary.HealthyClusters++
|
||||||
|
case "warning":
|
||||||
|
summary.WarningClusters++
|
||||||
|
case "error":
|
||||||
|
summary.ErrorClusters++
|
||||||
|
}
|
||||||
|
summary.TotalNodes += m.NodeCount
|
||||||
|
summary.TotalPods += m.PodCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeMetrics 获取集群的节点指标
|
||||||
|
func (s *MonitoringService) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||||
|
nodes, err := s.metricsClient.GetNodeMetrics(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get node metrics: %w", err)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
92
backend/internal/domain/service/registry_service.go
Normal file
92
backend/internal/domain/service/registry_service.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryService Registry 管理领域服务
|
||||||
|
type RegistryService struct {
|
||||||
|
registryRepo repository.RegistryRepository
|
||||||
|
ociClient repository.OCIClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistryService 创建 Registry 服务
|
||||||
|
func NewRegistryService(
|
||||||
|
registryRepo repository.RegistryRepository,
|
||||||
|
ociClient repository.OCIClient,
|
||||||
|
) *RegistryService {
|
||||||
|
return &RegistryService{
|
||||||
|
registryRepo: registryRepo,
|
||||||
|
ociClient: ociClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistry 创建新 Registry
|
||||||
|
func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
// 生成 ID
|
||||||
|
registry.ID = uuid.New().String()
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if err := registry.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
existingRegistry, _ := s.registryRepo.GetByName(ctx, registry.Name)
|
||||||
|
if existingRegistry != nil {
|
||||||
|
return entity.ErrRegistryExists
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.registryRepo.Create(ctx, registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistry 获取 Registry
|
||||||
|
func (s *RegistryService) GetRegistry(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
|
return s.registryRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRegistry 更新 Registry
|
||||||
|
func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
// 检查是否存在
|
||||||
|
_, err := s.registryRepo.GetByID(ctx, registry.ID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if err := registry.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.registryRepo.Update(ctx, registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRegistry 删除 Registry
|
||||||
|
func (s *RegistryService) DeleteRegistry(ctx context.Context, id string) error {
|
||||||
|
// 检查是否存在
|
||||||
|
_, err := s.registryRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.registryRepo.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegistries 列出所有 Registries
|
||||||
|
func (s *RegistryService) ListRegistries(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
|
return s.registryRepo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHealth 检查 Registry 健康状态
|
||||||
|
func (s *RegistryService) CheckHealth(ctx context.Context, id string) error {
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.CheckHealth(ctx, registry)
|
||||||
|
}
|
||||||
|
|
||||||
128
backend/internal/pkg/crypto/crypto.go
Normal file
128
backend/internal/pkg/crypto/crypto.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encryptor 加密器接口
|
||||||
|
type Encryptor interface {
|
||||||
|
Encrypt(plaintext string) (string, error)
|
||||||
|
Decrypt(ciphertext string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AESEncryptor AES 加密器
|
||||||
|
type AESEncryptor struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAESEncryptor 创建 AES 加密器
|
||||||
|
// key: 加密密钥(会自动派生为32字节密钥)
|
||||||
|
func NewAESEncryptor(key string) *AESEncryptor {
|
||||||
|
// 使用 SHA256 派生固定长度的密钥
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
return &AESEncryptor{
|
||||||
|
key: hash[:],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt 加密字符串
|
||||||
|
// 返回 Base64 编码的密文
|
||||||
|
func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
|
||||||
|
if plaintext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 GCM 模式
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机 nonce
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密(nonce 会自动附加到密文前面)
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
|
||||||
|
// Base64 编码
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密字符串
|
||||||
|
// 输入为 Base64 编码的密文
|
||||||
|
func (e *AESEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||||
|
if ciphertext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64 解码
|
||||||
|
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 nonce 和实际密文
|
||||||
|
nonce, cipherBytes := data[:nonceSize], data[nonceSize:]
|
||||||
|
|
||||||
|
// 解密
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, cipherBytes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSensitiveData 脱敏显示敏感数据
|
||||||
|
// 如果数据为空或已加密标记,返回掩码
|
||||||
|
func MaskSensitiveData(data string) string {
|
||||||
|
if data == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "••••••••" // 统一返回8个点,不泄露长度信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEncrypted 检查字符串是否已加密
|
||||||
|
// 简单检查:加密后的数据是 Base64 格式且长度较长
|
||||||
|
func IsEncrypted(data string) bool {
|
||||||
|
if data == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 加密后的数据至少有 nonce(12) + tag(16) + 内容,Base64后会更长
|
||||||
|
if len(data) < 40 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 尝试 Base64 解码
|
||||||
|
_, err := base64.StdEncoding.DecodeString(data)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
124
backend/internal/pkg/crypto/crypto_test.go
Normal file
124
backend/internal/pkg/crypto/crypto_test.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAESEncryptor(t *testing.T) {
|
||||||
|
encryptor := NewAESEncryptor("test-secret-key")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
plaintext string
|
||||||
|
}{
|
||||||
|
{"simple password", "password123"},
|
||||||
|
{"harbor password", "BWGDIP@ssw0rd1401#"},
|
||||||
|
{"empty string", ""},
|
||||||
|
{"long certificate", "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pP"},
|
||||||
|
{"unicode", "密码123!@#"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 测试加密
|
||||||
|
encrypted, err := encryptor.Encrypt(tt.plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空字符串应该返回空
|
||||||
|
if tt.plaintext == "" {
|
||||||
|
if encrypted != "" {
|
||||||
|
t.Errorf("Expected empty encrypted string, got %s", encrypted)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密后应该不同
|
||||||
|
if encrypted == tt.plaintext {
|
||||||
|
t.Errorf("Encrypted text should differ from plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试解密
|
||||||
|
decrypted, err := encryptor.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密后应该相同
|
||||||
|
if decrypted != tt.plaintext {
|
||||||
|
t.Errorf("Decrypted text mismatch: got %s, want %s", decrypted, tt.plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskSensitiveData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"normal password", "password123", "••••••••"},
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"long string", "very-long-password-with-many-characters", "••••••••"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := MaskSensitiveData(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("MaskSensitiveData(%s) = %s, want %s", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEncrypted(t *testing.T) {
|
||||||
|
encryptor := NewAESEncryptor("test-key")
|
||||||
|
|
||||||
|
plaintext := "password123"
|
||||||
|
encrypted, _ := encryptor.Encrypt(plaintext)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"encrypted data", encrypted, true},
|
||||||
|
{"plaintext", "password123", false},
|
||||||
|
{"empty string", "", false},
|
||||||
|
{"short string", "abc", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := IsEncrypted(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsEncrypted(%s) = %v, want %v", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptionConsistency(t *testing.T) {
|
||||||
|
encryptor := NewAESEncryptor("consistent-key")
|
||||||
|
plaintext := "test-password"
|
||||||
|
|
||||||
|
// 多次加密同一内容,结果应该不同(因为使用随机 nonce)
|
||||||
|
encrypted1, _ := encryptor.Encrypt(plaintext)
|
||||||
|
encrypted2, _ := encryptor.Encrypt(plaintext)
|
||||||
|
|
||||||
|
if encrypted1 == encrypted2 {
|
||||||
|
t.Error("Multiple encryptions of same plaintext should produce different ciphertexts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 但解密结果应该相同
|
||||||
|
decrypted1, _ := encryptor.Decrypt(encrypted1)
|
||||||
|
decrypted2, _ := encryptor.Decrypt(encrypted2)
|
||||||
|
|
||||||
|
if decrypted1 != plaintext || decrypted2 != plaintext {
|
||||||
|
t.Error("Decryption should produce original plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
123
backend/internal/pkg/jwt/jwt.go
Normal file
123
backend/internal/pkg/jwt/jwt.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessTokenDuration = 24 * time.Hour // Access Token 有效期
|
||||||
|
RefreshTokenDuration = 7 * 24 * time.Hour // Refresh Token 有效期
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTManager JWT 管理器
|
||||||
|
type JWTManager struct {
|
||||||
|
secretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTManager 创建 JWT 管理器
|
||||||
|
func NewJWTManager(secretKey string) *JWTManager {
|
||||||
|
return &JWTManager{
|
||||||
|
secretKey: secretKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims JWT Claims
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 生成 Access Token 和 Refresh Token
|
||||||
|
func (m *JWTManager) Generate(userID, username string) (accessToken, refreshToken string, err error) {
|
||||||
|
// 生成 Access Token
|
||||||
|
accessClaims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessToken, err = accessTokenObj.SignedString([]byte(m.secretKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to sign access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Refresh Token
|
||||||
|
refreshClaims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
|
refreshToken, err = refreshTokenObj.SignedString([]byte(m.secretKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 验证 Token
|
||||||
|
func (m *JWTManager) Verify(tokenString string) (userID, username string, err error) {
|
||||||
|
userID, username, _, err = m.VerifyWithIssuedAt(tokenString)
|
||||||
|
return userID, username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWithIssuedAt 验证 Token 并返回签发时间
|
||||||
|
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username string, issuedAt int64, err error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(m.secretKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, fmt.Errorf("failed to parse token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims.UserID, claims.Username, claims.IssuedAt.Unix(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", 0, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 刷新 Token
|
||||||
|
func (m *JWTManager) Refresh(refreshToken string) (string, error) {
|
||||||
|
// 验证 Refresh Token
|
||||||
|
userID, username, err := m.Verify(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的 Access Token
|
||||||
|
accessClaims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
newAccessToken, err := accessTokenObj.SignedString([]byte(m.secretKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign new access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
97
backend/internal/pkg/password/password.go
Normal file
97
backend/internal/pkg/password/password.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package password
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Argon2id 参数
|
||||||
|
memory = 64 * 1024 // 64 MB
|
||||||
|
iterations = 3
|
||||||
|
parallelism = 2
|
||||||
|
saltLength = 16
|
||||||
|
keyLength = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hasher 密码哈希器
|
||||||
|
type Hasher struct{}
|
||||||
|
|
||||||
|
// NewHasher 创建密码哈希器
|
||||||
|
func NewHasher() *Hasher {
|
||||||
|
return &Hasher{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash 哈希密码(使用 Argon2id)
|
||||||
|
func (h *Hasher) Hash(password string) (string, error) {
|
||||||
|
// 生成随机 salt
|
||||||
|
salt := make([]byte, saltLength)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Argon2id 哈希密码
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLength)
|
||||||
|
|
||||||
|
// 编码为字符串格式: $argon2id$v=19$m=65536,t=3,p=2$salt$hash
|
||||||
|
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)
|
||||||
|
|
||||||
|
return encodedHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 验证密码
|
||||||
|
func (h *Hasher) Verify(password, encodedHash string) error {
|
||||||
|
// 解析编码的哈希
|
||||||
|
parts := strings.Split(encodedHash, "$")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return fmt.Errorf("invalid hash format")
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1] != "argon2id" {
|
||||||
|
return fmt.Errorf("unsupported algorithm: %s", parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析参数
|
||||||
|
var version int
|
||||||
|
var m, t, p uint32
|
||||||
|
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse parameters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码 salt 和 hash
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用相同参数哈希输入的密码
|
||||||
|
computedHash := argon2.IDKey([]byte(password), salt, t, m, uint8(p), uint32(len(hash)))
|
||||||
|
|
||||||
|
// 使用常量时间比较防止时序攻击
|
||||||
|
if subtle.ConstantTimeCompare(hash, computedHash) == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("password does not match")
|
||||||
|
}
|
||||||
|
|
||||||
273
backend/scripts/docker-quick-start.sh
Executable file
273
backend/scripts/docker-quick-start.sh
Executable file
@ -0,0 +1,273 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ==================================================
|
||||||
|
# OCDP Backend - Docker Compose 快速启动脚本
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 打印带颜色的消息
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}$1${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 Docker
|
||||||
|
check_docker() {
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
print_error "Docker 未安装,请先安装 Docker"
|
||||||
|
echo "访问: https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose version &> /dev/null; then
|
||||||
|
print_error "Docker Compose 未安装,请先安装 Docker Compose V2"
|
||||||
|
echo "访问: https://docs.docker.com/compose/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Docker 环境检查通过"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查环境变量文件
|
||||||
|
check_env_file() {
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
print_warning ".env 文件不存在,正在从 env.example 创建..."
|
||||||
|
cp env.example .env
|
||||||
|
print_success ".env 文件已创建"
|
||||||
|
print_warning "请编辑 .env 文件,配置必要的环境变量(特别是生产环境的密钥)"
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键继续..."
|
||||||
|
else
|
||||||
|
print_success ".env 文件已存在"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示菜单
|
||||||
|
show_menu() {
|
||||||
|
print_header "OCDP Backend - Docker Compose 快速启动"
|
||||||
|
|
||||||
|
echo "请选择运行模式:"
|
||||||
|
echo ""
|
||||||
|
echo " 1) Mock 模式 (无需数据库,快速测试)"
|
||||||
|
echo " 2) 生产模式 (完整功能,需要数据库)"
|
||||||
|
echo " 3) 开发模式 (热重载,需要数据库)"
|
||||||
|
echo " 4) 查看服务状态"
|
||||||
|
echo " 5) 查看日志"
|
||||||
|
echo " 6) 停止所有服务"
|
||||||
|
echo " 7) 启动 pgAdmin (数据库管理)"
|
||||||
|
echo " 0) 退出"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 Mock 模式
|
||||||
|
start_mock() {
|
||||||
|
print_header "启动 Mock 模式"
|
||||||
|
print_info "正在启动服务..."
|
||||||
|
|
||||||
|
docker compose --profile mock up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_success "Mock 模式启动成功!"
|
||||||
|
echo ""
|
||||||
|
print_info "服务访问地址:"
|
||||||
|
echo " 📍 API: http://localhost:8080/api/v1"
|
||||||
|
echo " 📍 Health: http://localhost:8080/health"
|
||||||
|
echo ""
|
||||||
|
print_info "查看日志: docker compose logs -f backend-mock"
|
||||||
|
print_info "停止服务: docker compose down"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动生产模式
|
||||||
|
start_production() {
|
||||||
|
print_header "启动生产模式"
|
||||||
|
print_info "正在启动数据库和后端服务..."
|
||||||
|
|
||||||
|
docker compose --profile production up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_info "等待服务就绪..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查服务健康
|
||||||
|
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
||||||
|
print_success "生产模式启动成功!"
|
||||||
|
else
|
||||||
|
print_warning "服务正在启动中,请稍候..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_info "服务访问地址:"
|
||||||
|
echo " 📍 API: http://localhost:8080/api/v1"
|
||||||
|
echo " 📍 Health: http://localhost:8080/health"
|
||||||
|
echo ""
|
||||||
|
print_info "数据库信息:"
|
||||||
|
echo " 📍 Host: localhost"
|
||||||
|
echo " 📍 Port: 5432"
|
||||||
|
echo " 📍 Database: ocdp"
|
||||||
|
echo ""
|
||||||
|
print_info "查看日志: docker compose logs -f backend"
|
||||||
|
print_info "停止服务: docker compose down"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动开发模式
|
||||||
|
start_development() {
|
||||||
|
print_header "启动开发模式"
|
||||||
|
print_info "正在启动开发环境(支持热重载)..."
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_info "等待服务就绪..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
print_success "开发模式启动成功!"
|
||||||
|
echo ""
|
||||||
|
print_info "服务访问地址:"
|
||||||
|
echo " 📍 API: http://localhost:8080/api/v1"
|
||||||
|
echo " 📍 Health: http://localhost:8080/health"
|
||||||
|
echo ""
|
||||||
|
print_info "开发模式特性:"
|
||||||
|
echo " 🔥 支持代码热重载(修改代码自动重启)"
|
||||||
|
echo " 📂 源代码已挂载到容器"
|
||||||
|
echo ""
|
||||||
|
print_info "查看日志: docker compose logs -f backend-dev"
|
||||||
|
print_info "停止服务: docker compose down"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
show_status() {
|
||||||
|
print_header "服务状态"
|
||||||
|
docker compose ps
|
||||||
|
}
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
show_logs() {
|
||||||
|
print_header "查看日志"
|
||||||
|
echo "实时查看日志(按 Ctrl+C 退出)..."
|
||||||
|
echo ""
|
||||||
|
sleep 2
|
||||||
|
docker compose logs -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
stop_services() {
|
||||||
|
print_header "停止服务"
|
||||||
|
print_info "正在停止所有服务..."
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
print_success "所有服务已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 pgAdmin
|
||||||
|
start_pgadmin() {
|
||||||
|
print_header "启动 pgAdmin"
|
||||||
|
print_info "正在启动 pgAdmin..."
|
||||||
|
|
||||||
|
docker compose --profile tools up -d pgadmin
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_success "pgAdmin 启动成功!"
|
||||||
|
echo ""
|
||||||
|
print_info "访问地址: http://localhost:5050"
|
||||||
|
print_info "登录信息:"
|
||||||
|
echo " 📧 邮箱: admin@ocdp.local"
|
||||||
|
echo " 🔑 密码: admin"
|
||||||
|
echo ""
|
||||||
|
print_info "连接数据库配置:"
|
||||||
|
echo " 📍 Host: postgres"
|
||||||
|
echo " 📍 Port: 5432"
|
||||||
|
echo " 📍 Database: ocdp"
|
||||||
|
echo " 📍 Username: postgres"
|
||||||
|
echo " 📍 Password: postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
# 获取脚本目录
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
# 检查环境
|
||||||
|
check_docker
|
||||||
|
check_env_file
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
read -p "请选择 [0-7]: " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
start_mock
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
start_production
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
start_development
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
show_status
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
show_logs
|
||||||
|
;;
|
||||||
|
6)
|
||||||
|
stop_services
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
7)
|
||||||
|
start_pgadmin
|
||||||
|
echo ""
|
||||||
|
read -p "按回车键返回菜单..."
|
||||||
|
;;
|
||||||
|
0)
|
||||||
|
print_info "再见!"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "无效的选择,请重试"
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user